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 diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml index 25eb65317..90c75d147 100644 --- a/.github/workflows/android-build.yml +++ b/.github/workflows/android-build.yml @@ -22,13 +22,17 @@ jobs: # This workflow contains a single job called "build" build: # The type of runner that the job will run on - runs-on: macos-latest + runs-on: macos-13 # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v2 + # Runs a single command using the runners shell + - name: Prints related environment variables so that we can know what to set + run: env | egrep "JAVA|PATH|ANDROID" + # Runs a single command using the runners shell - name: Print the java and gradle versions run: | diff --git a/.github/workflows/code-coverage.yml b/.github/workflows/code-coverage.yml new file mode 100644 index 000000000..dc1af47ac --- /dev/null +++ b/.github/workflows/code-coverage.yml @@ -0,0 +1,36 @@ +name: code coverage +on: + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + +jobs: + run-codecov: + if: github.event.pull_request.draft == false + runs-on: macos-latest + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v2 + + - name: Setup the serve environment + shell: bash -l {0} + run: | + bash setup/setup_serve.sh + + - name: Run Jest tests + shell: bash -l {0} + run: | + source setup/activate_serve.sh + npx jest + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/coverage-final.json + flags: unit + fail_ci_if_error: ${{ github.repository == 'e-mission/e-mission-phone' }} + \ No newline at end of file diff --git a/.github/workflows/ios-build.yml b/.github/workflows/ios-build.yml index ad0ce2f01..695ed02de 100644 --- a/.github/workflows/ios-build.yml +++ b/.github/workflows/ios-build.yml @@ -22,7 +22,7 @@ jobs: # This workflow contains a single job called "build" build: # The type of runner that the job will run on - runs-on: macos-latest + runs-on: macos-14 # Steps represent a sequence of tasks that will be executed as part of the job steps: diff --git a/.github/workflows/prettier.yml b/.github/workflows/prettier.yml new file mode 100644 index 000000000..330a57729 --- /dev/null +++ b/.github/workflows/prettier.yml @@ -0,0 +1,12 @@ +name: prettier +on: + pull_request: + +jobs: + run-prettier: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - run: npx prettier@3.1.0 --check www + + diff --git a/.github/workflows/serve-install.yml b/.github/workflows/serve-install.yml index a5e634821..3c42514e5 100644 --- a/.github/workflows/serve-install.yml +++ b/.github/workflows/serve-install.yml @@ -9,10 +9,13 @@ on: branches: - master - maint_upgrade_** + - ui_feature_** pull_request: branches: - 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' @@ -56,11 +59,6 @@ jobs: echo "ionic version" npx ionic --version - - name: Run Jest tests - shell: bash -l {0} - run: | - npx jest - # TODO: figure out how to check that a server started correctly # - name: Try starting it # run: npx run serve diff --git a/.gitignore b/.gitignore index 6801f890d..7fcb2964d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ platforms/ plugins/ +dist/ www/lib/ package-hooks/ .DS_Store @@ -16,3 +17,4 @@ app-settings.json www/dist/ config.xml package.json +coverage/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..090654d94 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,12 @@ +# Ignore www/dist, json +www/dist +www/json + +# Ignore all HTML files: +**/*.html + +# This is the pattern to check only www directory +# Ignore all +/* +# but don't ignore all the files in www directory +!/www 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 +} diff --git a/README.md b/README.md index a1f23e99a..d75b62f6f 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,9 @@ e-mission phone app 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 been upgraded to the latest **Android**, **iOS**, **cordova-lib**, **node** and **npm** versions. __This is ready to build out of the box.__ + +The currently supported versions are in [`package.cordovabuild.json`](package.cordovabuild.json) Additional Documentation --- @@ -12,9 +14,17 @@ 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. +## Contents +#### 1. [Updating the UI only](#updating-the-ui-only) +#### 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) + 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?branch=master&event=push)](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). @@ -23,22 +33,13 @@ If you want to make only UI changes, (as opposed to modifying the existing plugi 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 +bash setup/setup_serve.sh ``` ### Activation (after install, and in every new shell) ``` -$ source setup/activate_serve.sh +source setup/activate_serve.sh ``` ### Running @@ -46,7 +47,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,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 @@ -65,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 --- @@ -78,14 +81,15 @@ 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. 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 --- -[![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-build-ios](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml/badge.svg?branch=master&event=push)](https://github.com/e-mission/e-mission-phone/actions/workflows/ios-build.yml?event-push) +[![osx-build-android](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml/badge.svg?branch=master&event=push)](https://github.com/e-mission/e-mission-phone/actions/workflows/android-build.yml?event=push) +[![osx-android-prereq-sdk-install](https://github.com/e-mission/e-mission-phone/actions/workflows/android-automated-sdk-install.yml/badge.svg?branch=master&event=push)](https://github.com/e-mission/e-mission-phone/actions/workflows/android-automated-sdk-install.yml?event=push) Pre-requisites --- @@ -97,7 +101,7 @@ Pre-requisites - 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 + bash setup/prereq_android_sdk_install.sh ```
Expected output @@ -142,27 +146,38 @@ Installing (one time only) Run the setup script for the platform you want to build ``` -$ bash setup/setup_android_native.sh -AND/OR -$ bash setup/setup_ios_native.sh +bash setup/setup_android_native.sh ``` - -**(optional)** Configure by changing the files in `www/json`. -Defaults are in `www/json/*.sample` - +AND/OR ``` -$ ls www/json/*.sample -$ cp www/json/startupConfig.json.sample www/json/startupConfig.json -$ cp ..... www/json/connectionConfig.json +bash setup/setup_ios_native.sh ``` ### Activation (after install, and in every new shell) ``` -$ source setup/activate_native.sh +source setup/activate_native.sh ``` -### Activation (after install, and in every new shell) +
Expected Output + +``` +Activating nvm +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 +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 +``` + +
+ + +### 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 @@ -172,14 +187,29 @@ If connecting to a development server over http, make sure to turn on http suppo ``` -### Run in the emulator +### Building the app + +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) ``` -$ npx cordova emulate ios -AND/OR -$ npx cordova emulate android +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 ``` +
+ Creating logos --- If you are building your own version of the app, you must have your own logo to @@ -223,19 +253,19 @@ Contributing Add the main repo as upstream - $ git remote add upstream https://github.com/covid19database/phone-app.git + 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 + git checkout -b mybranch Make changes to the branch and commit them - $ git commit + git commit Push the changes to your local fork - $ git push origin mybranch + git push origin mybranch Generate a pull request from the UI @@ -243,8 +273,14 @@ 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 ``` diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 000000000..b3c97fe4c --- /dev/null +++ b/babel.config.js @@ -0,0 +1,4 @@ +module.exports = { + presets: ['@babel/preset-env', '@babel/preset-typescript', '@babel/preset-react'], + plugins: ['@babel/plugin-transform-flow-strip-types'], +} diff --git a/bin/sign_and_align_keys.sh b/bin/sign_and_align_keys.sh index 261058bd5..9b60c3ade 100644 --- a/bin/sign_and_align_keys.sh +++ b/bin/sign_and_align_keys.sh @@ -4,13 +4,13 @@ PROJECT=$1 VERSION=$2 if [[ $# -eq 0 ]]; then - echo "No arguments supplied" + echo "sign_and_align_keys " exit 1 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 diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..125b560ef --- /dev/null +++ b/codecov.yml @@ -0,0 +1,8 @@ +comment: + layout: " diff, flags, files" + behavior: default + require_changes: false + require_base: false + require_head: true + hide_project_coverage: false + \ No newline at end of file diff --git a/config.cordovabuild.xml b/config.cordovabuild.xml index d3d562802..3401b9b9b 100644 --- a/config.cordovabuild.xml +++ b/config.cordovabuild.xml @@ -36,6 +36,8 @@ + + diff --git a/hooks/before_build/ios/ios_change_deployment.js b/hooks/before_build/ios/ios_change_deployment.js new file mode 100644 index 000000000..ad381162d --- /dev/null +++ b/hooks/before_build/ios/ios_change_deployment.js @@ -0,0 +1,37 @@ +const fs = require('fs'); +const path = require('path'); + +function findFilePathsByFilename(directory, filename) { + const files = fs.readdirSync(directory); + const filePaths = []; + + for (const file of files) { + const filePath = path.join(directory, file); + const stats = fs.statSync(filePath); + + if (stats.isDirectory()) { + // Recursively search in subdirectories + const subdirectoryFilePaths = findFilePathsByFilename(filePath, filename); + filePaths.push(...subdirectoryFilePaths); + } else if (stats.isFile() && file === filename) { + // If the file matches the filename, add its path to the result + filePaths.push(filePath); + } + } + return filePaths; +} + + +const paths1 = findFilePathsByFilename('.', 'project.pbxproj'); +const paths2 = findFilePathsByFilename('.', 'Pods.xcodeproj'); +const paths = paths1.concat(paths2) + +console.log('Apply patch to', paths); + +for (let path of paths) { + let content = fs.readFileSync(path, { encoding: 'utf-8' }); + content = content.replace(/IPHONEOS_DEPLOYMENT_TARGET = [0-9]+.0;/g, 'IPHONEOS_DEPLOYMENT_TARGET = 13.0;'); + fs.writeFileSync(path, content); +} + +console.log('Done setting IPHONEOS_DEPLOYMENT_TARGET'); diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 000000000..939834e51 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,26 @@ +module.exports = { + testEnvironment: 'jsdom', + testPathIgnorePatterns: [ + "/node_modules/", + "/dist/", + "/platforms/", + "/plugins/", + "/lib/", + ], + preset: 'react-native', + transform: { + "^.+\\.(ts|tsx|js|jsx)$": "babel-jest" + }, + transformIgnorePatterns: [ + "node_modules/(?!((enketo-transformer/dist/enketo-transformer/web)|(jest-)?react-native(-.*)?|@react-native(-community)?)/)", + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + moduleDirectories: ["node_modules", "src"], + globals: {"__DEV__": false}, + collectCoverage: true, + collectCoverageFrom: [ + "www/js/**/*.{ts,tsx,js,jsx}", + "!www/js/**/index.{ts,tsx,js,jsx}", + "!www/js/types/**/*.{ts,tsx,js,jsx}", + ], +}; diff --git a/jest.config.json b/jest.config.json deleted file mode 100644 index 78dc839b4..000000000 --- a/jest.config.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "testPathIgnorePatterns": [ - "/node_modules/", - "/platforms/", - "/plugins/", - "/lib/", - "/manual_lib/" - ], - "transform": { - "^.+\\.(ts|tsx|js|jsx)$": "ts-jest" - }, - "moduleNameMapper": { - "^react-native$": "react-native-web" - } -} diff --git a/package.cordovabuild.json b/package.cordovabuild.json index b5d69872f..282e3693d 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -26,9 +26,8 @@ "@babel/preset-typescript": "^7.21.4", "@ionic/cli": "6.20.8", "@types/luxon": "^3.3.0", - "@types/react": "^18.2.20", + "@types/react": "~18.2.0", "babel-loader": "^9.1.2", - "babel-plugin-angularjs-annotate": "^0.10.0", "babel-plugin-optional-require": "^0.3.1", "concurrently": "^8.0.1", "cordova": "^11.1.0", @@ -97,22 +96,19 @@ "cordova-plugin-androidx-adapter": {}, "phonegap-plugin-barcodescanner": { "ANDROID_SUPPORT_V4_VERSION": "27.+" - } + }, + "cordova-plugin-bluetooth-classic-serial-port": {}, + "cordova-custom-config": {}, + "cordova-plugin-ibeacon": {} } }, "dependencies": { "@havesource/cordova-plugin-push": "git+https://github.com/havesource/cordova-plugin-push.git#4.0.0-dev.0", + "@messageformat/core": "^3.2.0", "@react-navigation/native": "^6.1.7", "@react-navigation/stack": "^6.3.17", "@shopify/flash-list": "^1.3.1", - "angular": "1.6.7", - "angular-animate": "1.6.7", - "angular-local-storage": "^0.7.1", - "angular-sanitize": "1.6.7", - "angular-simple-logger": "^0.1.7", - "angular-translate": "^2.18.1", - "angular-translate-loader-static-files": "^2.18.1", - "angular-ui-router": "0.2.13", + "@types/leaflet": "^1.9.4", "animate.css": "^3.5.2", "bottleneck": "^2.19.5", "chart.js": "^4.3.0", @@ -125,13 +121,13 @@ "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.8.0", + "cordova-plugin-em-datacollection": "git+https://github.com/e-mission/e-mission-data-collection.git#v1.8.5", "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", "cordova-plugin-em-settings": "git+https://github.com/e-mission/cordova-connection-settings.git#v1.2.3", "cordova-plugin-em-unifiedlogger": "git+https://github.com/e-mission/cordova-unified-logger.git#v1.3.6", - "cordova-plugin-em-usercache": "git+https://github.com/e-mission/cordova-usercache.git#v1.1.6", + "cordova-plugin-em-usercache": "git+https://github.com/e-mission/cordova-usercache.git#v1.1.9", "cordova-plugin-email-composer": "git+https://github.com/katzer/cordova-plugin-email-composer.git#0.10.1", "cordova-plugin-file": "8.0.0", "cordova-plugin-inappbrowser": "5.0.0", @@ -139,30 +135,28 @@ "cordova-plugin-ionic-webview": "5.0.0", "cordova-plugin-local-notification-12": "github:e-mission/cordova-plugin-local-notification-12#v0.1.4-fix-android-action", "cordova-plugin-x-socialsharing": "6.0.4", + "cordova-plugin-bluetooth-classic-serial-port": "git+https://github.com/louisg1337/cordova-plugin-bluetooth-classic-serial-port.git", + "cordova-custom-config": "^5.1.1", + "cordova-plugin-ibeacon": "git+https://github.com/louisg1337/cordova-plugin-ibeacon.git", "core-js": "^2.5.7", "enketo-core": "^6.1.7", + "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", "fs-extra": "^9.0.1", - "i18next": "^22.5.0", + "humanize-duration": "^3.31.0", + "i18next": "^23.7.6", "install": "^0.13.0", - "ionic-datepicker": "1.2.1", - "ionic-toast": "^0.4.1", - "jquery": "^3.1.0", "klaw-sync": "^6.0.0", "leaflet": "^1.9.4", "luxon": "^3.3.0", - "messageformat": "^2.3.0", - "moment": "^2.29.4", - "moment-timezone": "^0.5.43", - "ng-i18next": "^1.0.7", "npm": "^9.6.3", "phonegap-plugin-barcodescanner": "git+https://github.com/phonegap/phonegap-plugin-barcodescanner#v8.1.0", "prop-types": "^15.8.1", - "react": "^18.2.*", + "react": "~18.2.0", "react-chartjs-2": "^5.2.0", - "react-dom": "^18.2.*", - "react-i18next": "^12.3.1", - "react-native-paper": "^5.8.0", + "react-dom": "~18.2.0", + "react-i18next": "^13.5.0", + "react-native-paper": "^5.11.0", "react-native-paper-dates": "^0.18.12", "react-native-safe-area-context": "^4.6.3", "react-native-screens": "^3.22.0", diff --git a/package.serve.json b/package.serve.json index 1a2ef6cb0..f6f5c2ae3 100644 --- a/package.serve.json +++ b/package.serve.json @@ -18,15 +18,17 @@ "@babel/core": "^7.21.3", "@babel/plugin-proposal-class-properties": "^7.18.6", "@babel/plugin-proposal-object-rest-spread": "^7.20.7", + "@babel/plugin-transform-flow-strip-types": "^7.22.5", "@babel/preset-env": "^7.21.4", "@babel/preset-flow": "^7.21.4", "@babel/preset-react": "^7.18.6", "@babel/preset-typescript": "^7.21.4", "@ionic/cli": "6.20.8", + "@testing-library/react-native": "^12.3.0", "@types/luxon": "^3.3.0", - "@types/react": "^18.2.20", + "@types/react": "~18.2.0", + "babel-jest": "^29.7.0", "babel-loader": "^9.1.2", - "babel-plugin-angularjs-annotate": "^0.10.0", "babel-plugin-optional-require": "^0.3.1", "concurrently": "^8.0.1", "cordova": "^11.1.0", @@ -35,31 +37,28 @@ "expose-loader": "^4.1.0", "file-loader": "^6.2.0", "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", "phonegap": "9.0.0+cordova.9.0.0", "process": "^0.11.10", + "react-test-renderer": "~18.2.0", "sass": "^1.62.1", "sass-loader": "^13.3.1", "style-loader": "^3.3.3", - "ts-jest": "^29.1.1", "ts-loader": "^9.4.2", + "ts-node": "^10.9.1", "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.1.0" }, "dependencies": { + "@messageformat/core": "^3.2.0", "@react-navigation/native": "^6.1.7", "@react-navigation/stack": "^6.3.17", "@shopify/flash-list": "^1.3.1", "@types/jest": "^29.5.5", - "angular": "1.6.7", - "angular-animate": "1.6.7", - "angular-local-storage": "^0.7.1", - "angular-sanitize": "1.6.7", - "angular-simple-logger": "^0.1.7", - "angular-translate": "^2.18.1", - "angular-translate-loader-static-files": "^2.18.1", - "angular-ui-router": "0.2.13", + "@types/leaflet": "^1.9.4", "animate.css": "^3.5.2", "bottleneck": "^2.19.5", "chart.js": "^4.3.0", @@ -67,27 +66,22 @@ "chartjs-plugin-annotation": "^3.0.1", "core-js": "^2.5.7", "enketo-core": "^6.1.7", + "enketo-transformer": "^4.0.0", "fast-xml-parser": "^4.2.2", "fs-extra": "^9.0.1", - "i18next": "^22.5.0", + "humanize-duration": "^3.31.0", + "i18next": "^23.7.6", "install": "^0.13.0", - "ionic-datepicker": "1.2.1", - "ionic-toast": "^0.4.1", - "jquery": "^3.1.0", "klaw-sync": "^6.0.0", "leaflet": "^1.9.4", "luxon": "^3.3.0", - "messageformat": "^2.3.0", - "moment": "^2.29.4", - "moment-timezone": "^0.5.43", - "ng-i18next": "^1.0.7", "npm": "^9.6.3", "prop-types": "^15.8.1", - "react": "^18.2.*", + "react": "~18.2.0", "react-chartjs-2": "^5.2.0", - "react-dom": "^18.2.*", - "react-i18next": "^12.3.1", - "react-native-paper": "^5.8.0", + "react-dom": "~18.2.0", + "react-i18next": "^13.5.0", + "react-native-paper": "^5.11.0", "react-native-paper-dates": "^0.18.12", "react-native-safe-area-context": "^4.6.3", "react-native-screens": "^3.22.0", diff --git a/setup/android_sdk_packages b/setup/android_sdk_packages index a62d6b18a..a870852f1 100644 --- a/setup/android_sdk_packages +++ b/setup/android_sdk_packages @@ -4,7 +4,6 @@ build-tools;33.0.2 build-tools;34.0.0 emulator extras;google;google_play_services -patcher;v4 platform-tools platforms;android-30 platforms;android-31 diff --git a/setup/autoreload/macos-index.js b/setup/autoreload/macos-index.js index 4c2ad690d..23cee52ab 100644 --- a/setup/autoreload/macos-index.js +++ b/setup/autoreload/macos-index.js @@ -2,6 +2,7 @@ const os = require('os'); const nameMap = new Map([ + [23, ['Sonoma', '14.3.1']], [22, ['Ventura', '13']], [21, ['Monterey', '12']], [20, ['Big Sur', '11']], 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 diff --git a/setup/setup_ios_native.sh b/setup/setup_ios_native.sh index 07cd0e977..86ebe2c16 100644 --- a/setup/setup_ios_native.sh +++ b/setup/setup_ios_native.sh @@ -7,14 +7,32 @@ source setup/setup_shared.sh OSX_MAJOR_VERSION=`sw_vers | grep ProductVersion | cut -d ':' -f 2 | cut -d '.' -f 1` echo "Found OSX major version" $OSX_MAJOR_VERSION +# The Homebrew pac-man is installed in different locations, depending on whether the processor +# is an Intel or Apple Silicone chip. Intel uses x86_64, Apple chips are amd64, so we can +# check the chip type using these hardware platforms. +CHIP_ARC=`uname -m` +INTEL="x86_64" +APPLE_SILICONE="arm64" +WORKING_DIR="" + +if [ $CHIP_ARC == $INTEL ]; then + echo "Found "$INTEL" chip" + WORKING_DIR="/usr/local/" +else + if [ $CHIP_ARC == $APPLE_SILICONE ]; then + echo "Found "$APPLE_SILICONE" chip" + WORKING_DIR="/opt/homebrew/" + fi +fi + CURR_RUBY_VERSION=`ruby --version | cut -d ' ' -f 2 | cut -d '.' -f 1-2` echo "Found ruby version "$CURR_RUBY_VERSION if [ $CURR_RUBY_VERSION == $RUBY_VERSION ]; then echo "Found ruby version "$CURR_RUBY_VERSION" expected "$RUBY_VERSION" no need to upgrade" else - if [ -x /usr/local/bin/brew ]; then - echo "Found brew installation with version" `/usr/local/bin/brew --version` + if [ -x "${WORKING_DIR}/bin/brew" ]; then + echo "Found brew installation with version" ` brew --version` echo "Installing ruby version to brew" $RUBY_VERSION brew install ruby@$RUBY_VERSION else @@ -32,6 +50,6 @@ echo "Adding $RUBY_PATH to the path before the install" export PATH=$RUBY_PATH:$PATH echo "Installing cocoapods" -/usr/local/opt/ruby@$RUBY_VERSION/bin/gem install --no-document --user-install cocoapods -v $COCOAPODS_VERSION +${WORKING_DIR}/opt/ruby@$RUBY_VERSION/bin/gem install --no-document --user-install cocoapods -v $COCOAPODS_VERSION source setup/setup_shared_native.sh diff --git a/setup/setup_shared_native.sh b/setup/setup_shared_native.sh index 00c72a375..dfc52f072 100644 --- a/setup/setup_shared_native.sh +++ b/setup/setup_shared_native.sh @@ -23,7 +23,7 @@ sed -i -e "s|/usr/bin/env node|/usr/bin/env node --unhandled-rejections=strict|" npx cordova prepare -EXPECTED_COUNT=23 +EXPECTED_COUNT=26 INSTALLED_COUNT=`npx cordova plugin list | wc -l` echo "Found $INSTALLED_COUNT plugins, expected $EXPECTED_COUNT" if [ $INSTALLED_COUNT -lt $EXPECTED_COUNT ]; diff --git a/tsconfig.json b/tsconfig.json index 29384751e..d7181b706 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,13 +4,15 @@ "sourceMap": true, "module": "es6", "target": "es5", + "strictNullChecks": true, "downlevelIteration": true, "esModuleInterop": true, "resolveJsonModule": true, "jsx": "react", "allowJs": true, - "moduleResolution": "node" + "moduleResolution": "node", + "typeRoots": ["node_modules/@types", "www/js/types"], }, "include": ["www/**/*"], - "exclude": ["**/www/manual_lib/*", "**/node_modules/*", "**/dist/*"], + "exclude": ["**/node_modules/*", "**/dist/*"], } diff --git a/webpack.config.js b/webpack.config.js index 1e504ac5f..c31184a01 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -13,7 +13,6 @@ module.exports = { { test: /\.(scss|css)$/, include: [path.resolve(__dirname, 'www/css'), - path.resolve(__dirname, 'www/manual_lib'), path.resolve(__dirname, 'node_modules/enketo-core'), path.resolve(__dirname, 'node_modules/leaflet')], use: ['style-loader', 'css-loader', 'sass-loader'], @@ -57,11 +56,8 @@ module.exports = { ], }, plugins: [ - // to load jQuery and moment globally + // to load Leaflet globally new webpack.ProvidePlugin({ - $: 'jquery', - jQuery: 'jquery', - moment: 'moment', L: 'leaflet', }), new webpack.DefinePlugin({ @@ -79,7 +75,12 @@ module.exports = { /* Enketo expects its per-app configuration to be available as 'enketo-config', so we have to alias it here. https://github.com/enketo/enketo-core#global-configuration */ - 'enketo/config': path.resolve(__dirname, 'www/js/config/enketo-config') + 'enketo/config': path.resolve(__dirname, 'www/js/config/enketo-config'), + /* enketo-transformer has 'libxslt' as an optional peer dependency. + We don't need it since we are only doing client-side transformations via + enketo-transformer/web (https://github.com/enketo/enketo-transformer#web). + So, we can tell webpack it's ok to ignore libxslt by aliasing it to false. */ + 'libxslt': false, }, extensions: ['.web.js', '.jsx', '.tsx', '.ts', '.js'], }, diff --git a/webpack.prod.js b/webpack.prod.js index c08fc140c..231209d36 100644 --- a/webpack.prod.js +++ b/webpack.prod.js @@ -7,23 +7,6 @@ module.exports = merge(common, { devtool: 'source-map', module: { rules: [ - /* In production, Webpack minifies JS files and randomizes variable names. - This causes problems with AngularJS unless you use explicit annotations, - which we don't. - https://docs.angularjs.org/error/$injector/strictdi - (The syntax we use is like the 'bad' example: implicit annotations) - So rather than change every file in our codebase, I'm adding this - babel plugin which basically preprocesses our 'bad' code into 'good' code. - Only needed on production because minification doesn't happen on dev. */ - { - test: /\.(js)$/, - include: path.resolve(__dirname, 'www'), - loader: 'babel-loader', - options: { - presets: ['@babel/preset-env'], - plugins: ["angularjs-annotate"], - }, - }, { test: /\.(js|jsx|ts|tsx)$/, loader: 'babel-loader', diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts new file mode 100644 index 000000000..60ea4e0c1 --- /dev/null +++ b/www/__mocks__/cordovaMocks.ts @@ -0,0 +1,252 @@ +import packageJsonBuild from '../../package.cordovabuild.json'; + +export const mockCordova = () => { + window['cordova'] ||= {}; + window['cordova'].platformId ||= 'ios'; + window['cordova'].platformVersion ||= packageJsonBuild.dependencies['cordova-ios']; + window['cordova'].plugins ||= {}; +}; + +export const mockReminders = () => { + window['cordova'] ||= {}; + window['cordova'].plugins ||= {}; + window['cordova'].plugins.notification ||= {}; + window['cordova'].plugins.notification.local ||= {}; + window['cordova'].plugins.notification.local.getScheduled ||= () => []; + window['cordova'].plugins.notification.local.cancelAll ||= () => {}; + window['cordova'].plugins.notification.local.schedule ||= () => {}; +}; + +export const mockDevice = () => { + window['device'] ||= {}; + window['device'].platform ||= 'ios'; + window['device'].version ||= '14.0.0'; +}; + +export const mockGetAppVersion = () => { + const mockGetAppVersion = { + getAppName: () => new Promise((rs, rj) => setTimeout(() => rs('Mock App'), 10)), + 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', + tempDirectory: '../path/to/temp/directory', + }; +}; + +//for consent document +const _storage = {}; + +type MessageData = any; +type Message = { key: string; data: MessageData; metadata: { write_ts: number; [k: string]: any } }; +export const mockBEMUserCache = (config?) => { + const _cache = {}; + const messages: Message[] = []; + const mockBEMUserCache = { + getLocalStorage: (key: string, isSecure: boolean) => { + return new Promise((rs, rj) => + setTimeout(() => { + rs(_cache[key]); + }, 100), + ); + }, + putLocalStorage: (key: string, value: any) => { + return new Promise((rs, rj) => + setTimeout(() => { + _cache[key] = value; + rs(); + }, 100), + ); + }, + removeLocalStorage: (key: string) => { + return new Promise((rs, rj) => + setTimeout(() => { + delete _cache[key]; + rs(); + }, 100), + ); + }, + clearAll: () => { + return new Promise((rs, rj) => + setTimeout(() => { + for (let p in _cache) delete _cache[p]; + for (let doc in _storage) delete _storage[doc]; + rs(); + }, 100), + ); + }, + listAllLocalStorageKeys: () => { + return new Promise((rs, rj) => + setTimeout(() => { + rs(Object.keys(_cache)); + }, 100), + ); + }, + listAllUniqueKeys: () => { + return new Promise((rs, rj) => + setTimeout(() => { + rs(Object.keys(_cache)); + }, 100), + ); + }, + putMessage: (key: string, value: any) => { + return new Promise((rs, rj) => + setTimeout(() => { + messages.push({ + key, + data: value, + // write_ts is epoch time in seconds + metadata: { write_ts: Math.floor(Date.now() / 1000) }, + }); + rs(); + }, 100), + ); + }, + getAllMessages: (key: string, withMetadata?: boolean) => { + return new Promise((rs, rj) => + setTimeout(() => { + rs(messages.filter((m) => m.key == key).map((m) => (withMetadata ? m : m.data))); + }, 100), + ); + }, + getMessagesForInterval: (key: string, tq, withMetadata?: boolean) => { + return new Promise((rs, rj) => + setTimeout(() => { + rs( + messages + .filter((m) => m.key == key) + .filter((m) => m.metadata[tq.key] >= tq.startTs && m.metadata.write_ts <= tq.endTs) + .map((m) => (withMetadata ? m : m.data)), + ); + }, 100), + ); + }, // Used for getUnifiedDataForInterval + putRWDocument: (key: string, value: any) => { + if (key == 'config/app_ui_config') { + return new Promise((rs, rj) => + setTimeout(() => { + config = value; + rs(); + }, 100), + ); + } + }, + getDocument: (key: string, withMetadata?: boolean) => { + //returns the config provided as a paramenter to this mock! + if (key == 'config/app_ui_config') { + return new Promise((rs, rj) => + setTimeout(() => { + if (config) rs(config); + else rs({}); // return empty object if config is not set + }, 100), + ); + } else { + return new Promise((rs, rj) => + setTimeout(() => { + rs(_storage[key]); + }, 100), + ); + } + }, + isEmptyDoc: (doc) => { + if (doc == undefined) { + return true; + } + let string = doc.toString(); + if (string.length == 0) { + return true; + } else { + return false; + } + }, + getAllTimeQuery: () => { + return { key: 'write_ts', startTs: 0, endTs: Date.now() / 1000 }; + }, + getSensorDataForInterval: (key, tq, withMetadata) => { + if (key == `manual/demographic_survey`) { + return new Promise((rs, rj) => + setTimeout(() => { + rs({ metadata: { write_ts: '1699897723' }, data: 'completed', time: '01/01/2001' }); + }, 100), + ); + } else { + return undefined; + } + }, + }; + window['cordova'] ||= {}; + window['cordova'].plugins ||= {}; + window['cordova'].plugins.BEMUserCache = mockBEMUserCache; +}; + +export const mockBEMDataCollection = () => { + const mockBEMDataCollection = { + markConsented: (consentDoc) => { + setTimeout(() => { + _storage['config/consent'] = consentDoc; + }, 100); + }, + getConfig: () => { + return new Promise((rs, rj) => { + setTimeout(() => { + rs({ ios_use_remote_push_for_sync: true }); + }, 100); + }); + }, + handleSilentPush: () => { + return new Promise((rs, rj) => + setTimeout(() => { + rs(); + }, 100), + ); + }, + }; + window['cordova'] ||= {}; + window['cordova'].plugins.BEMDataCollection = mockBEMDataCollection; +}; + +export const mockBEMServerCom = () => { + const mockBEMServerCom = { + postUserPersonalData: (actionString, typeString, updateDoc, rs, rj) => { + setTimeout(() => { + console.log('set in mock', updateDoc); + _storage['user_data'] = updateDoc; + rs(); + }, 100); + }, + + getUserPersonalData: (actionString, rs, rj) => { + setTimeout(() => { + rs(_storage['user_data']); + }, 100); + }, + }; + window['cordova'].plugins.BEMServerComm = mockBEMServerCom; +}; + +let _url_stash = ''; + +export const mockInAppBrowser = () => { + const mockInAppBrowser = { + open: (url: string, mode: string, options: {}) => { + _url_stash = url; + }, + }; + window['cordova'].InAppBrowser = mockInAppBrowser; +}; + +export const getURL = () => { + return _url_stash; +}; + +export const clearURL = () => { + _url_stash = ''; +}; diff --git a/www/__mocks__/fakeConfig.json b/www/__mocks__/fakeConfig.json new file mode 100644 index 000000000..dabec6cd9 --- /dev/null +++ b/www/__mocks__/fakeConfig.json @@ -0,0 +1,88 @@ +{ + "version": 1, + "ts": 1655143472, + "server": { + "connectUrl": "https://openpath-test.nrel.gov/api/", + "aggregate_call_auth": "user_only" + }, + "intro": { + "program_or_study": "study", + "start_month": "10", + "start_year": "2023", + "program_admin_contact": "K. Shankari", + "deployment_partner_name": "NREL", + "translated_text": { + "en": { + "deployment_partner_name": "NREL", + "deployment_name": "Testing environment for Jest testing", + "summary_line_1": "", + "summary_line_2": "", + "summary_line_3": "", + "short_textual_description": "", + "why_we_collect": "", + "research_questions": ["", ""] + }, + "es": { + "deployment_partner_name": "NREL", + "deployment_name": "Ambiente prueba para las pruebas de Jest", + "summary_line_1": "", + "summary_line_2": "", + "summary_line_3": "", + "short_textual_description": "", + "why_we_collect": "", + "research_questions": ["", ""] + } + } + }, + "survey_info": { + "surveys": { + "TimeUseSurvey": { + "compatibleWith": 1, + "formPath": "https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json", + "labelTemplate": { + "en": "{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }", + "es": "{ erea, plural, =0 {} other {# Empleo/EducaciĆ³n, } }{ da, plural, =0 {} other {# Actividades domesticas, }}" + }, + "labelVars": { + "da": { + "key": "Domestic_activities", + "type": "length" + }, + "erea": { + "key": "Employment_related_a_Education_activities", + "type": "length" + } + }, + "version": 9 + } + }, + "trip-labels": "ENKETO" + }, + "display_config": { + "use_imperial": false + }, + "profile_controls": { + "support_upload": true, + "trip_end_notification": false + }, + "admin_dashboard": { + "overview_users": true, + "overview_active_users": true, + "overview_trips": true, + "overview_signup_trends": true, + "overview_trips_trend": true, + "data_uuids": true, + "data_trips": true, + "data_trips_columns_exclude": [], + "additional_trip_columns": [], + "data_uuids_columns_exclude": [], + "token_generate": true, + "token_prefix": "nrelop", + "map_heatmap": true, + "map_bubble": true, + "map_trip_lines": true, + "push_send": true, + "options_uuids": true, + "options_emails": true + } +} diff --git a/www/__mocks__/fakeLabels.json b/www/__mocks__/fakeLabels.json new file mode 100644 index 000000000..aaf433367 --- /dev/null +++ b/www/__mocks__/fakeLabels.json @@ -0,0 +1,212 @@ +{ + "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": "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": "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 + }, + { + "value": "unicycle", + "baseMode": "UNICYCLE" + } + ], + "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" + } + ] +} diff --git a/www/__mocks__/fileSystemMocks.ts b/www/__mocks__/fileSystemMocks.ts new file mode 100644 index 000000000..64865e843 --- /dev/null +++ b/www/__mocks__/fileSystemMocks.ts @@ -0,0 +1,28 @@ +export const mockFileSystem = () => { + type MockFileWriter = { + onreadend: any; + onerror: (e: any) => void; + write: (obj: Blob) => void; + }; + window['resolveLocalFileSystemURL'] = (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); + }, + nativeURL: 'file:///Users/Jest/test/URL/', + isFile: true, + }; + onSuccess(fileEntry); + }, + }, + }, + }; + console.log('in mock, fs is ', fs, ' get File is ', fs.filesystem.root.getFile); + handleFS(fs); + }; +}; diff --git a/www/__mocks__/globalMocks.ts b/www/__mocks__/globalMocks.ts new file mode 100644 index 000000000..4d591f55f --- /dev/null +++ b/www/__mocks__/globalMocks.ts @@ -0,0 +1,25 @@ +export const mockLogger = () => { + window['Logger'] = { log: console.log }; + window.alert = (msg) => { + console.log(msg); + }; + console.error = (msg) => { + console.log(msg); + }; +}; + +let alerts: string[] = []; + +export const mockAlert = () => { + window['alert'] = (message) => { + alerts.push(message); + }; +}; + +export const clearAlerts = () => { + alerts = []; +}; + +export const getAlerts = () => { + return alerts; +}; diff --git a/www/__mocks__/messageFormatMocks.ts b/www/__mocks__/messageFormatMocks.ts new file mode 100644 index 000000000..eadfc0c7c --- /dev/null +++ b/www/__mocks__/messageFormatMocks.ts @@ -0,0 +1,29 @@ +//call signature MessageFormat.compile(templage)(vars); +//in - template an vars -- {... pca: 0, ...} +//out - 1 Personal Care, + +export default class MessageFormat { + constructor(locale: string) {} + + compile(message: string) { + return (vars: {}) => { + let label = ''; + const brokenList = message.split('}{'); + console.log(brokenList); + + for (let key in vars) { + brokenList.forEach((item) => { + let brokenItem = item.split(','); + if (brokenItem[0] == key) { + let getLabel = brokenItem[2].split('#'); + console.log(getLabel); + label = vars[key] + ' ' + getLabel[1]; + return label; + } + }); + } + }; + } +} + +exports.MessageFormat = MessageFormat; diff --git a/www/__mocks__/pushNotificationMocks.ts b/www/__mocks__/pushNotificationMocks.ts new file mode 100644 index 000000000..4b2098076 --- /dev/null +++ b/www/__mocks__/pushNotificationMocks.ts @@ -0,0 +1,27 @@ +let notifSettings; +let onList: any = {}; +let called = null; + +export const mockPushNotification = () => { + window['PushNotification'] = { + init: (settings: Object) => { + notifSettings = settings; + return { + on: (event: string, callback: Function) => { + onList[event] = callback; + }, + finish: (content: any, errorFcn: Function, notID: any) => { + called = notID; + }, + }; + }, + }; +}; + +export function clearNotifMock() { + notifSettings = {}; + onList = {}; + called = null; +} +export const getOnList = () => onList; +export const getCalled = () => called; diff --git a/www/__mocks__/timelineHelperMocks.ts b/www/__mocks__/timelineHelperMocks.ts new file mode 100644 index 000000000..66011c4dd --- /dev/null +++ b/www/__mocks__/timelineHelperMocks.ts @@ -0,0 +1,238 @@ +import { LocalDt, MetaData, BEMData, ServerResponse } from '../js/types/serverData'; +import { + CompositeTrip, + ConfirmedPlace, + FilteredLocation, + TripTransition, + UnprocessedTrip, +} from '../js/types/diaryTypes'; +import { LabelOptions } from '../js/types/labelTypes'; +import { AppConfig } from '../js/types/appConfigTypes'; + +const mockMetaData: MetaData = { + write_ts: 1, + key: 'test/value/one', + platform: 'test', + time_zone: 'America/Los_Angeles', + write_fmt_time: '1969-07-16T07:01:49.000Z', + write_local_dt: null as any, + origin_key: '1', +}; + +export const mockLabelOptions: LabelOptions = { + MODE: null, + PURPOSE: null, + REPLACED_MODE: null, + translations: null, +} as unknown as LabelOptions; + +const mockConfirmedPlaceData = { + source: 'DwellSegmentationTimeFilter', + key: null, + origin_key: null, + location: { + type: 'Point', + coordinates: [-122.0876886, 37.3887767], + }, + cleaned_place: null, + additions: [], + user_input: {}, + enter_fmt_time: '2015-07-22T08:14:53.881000-07:00', + exit_fmt_time: '2015-07-22T08:14:53.881000-07:00', + starting_trip: null, + ending_trip: null, + enter_local_dt: null, + exit_local_dt: null, + raw_places: [null, null], + enter_ts: 1437578093.881, + exit_ts: 1437578093.881, +} as unknown as ConfirmedPlace; + +// using parse/stringify to deep copy & populate data +let tempMetaData = JSON.parse(JSON.stringify(mockMetaData)); +tempMetaData.write_ts = 2; +tempMetaData.origin_key = '2'; +export const mockMetaDataTwo = tempMetaData; + +export const mockUnprocessedTrip = { + _id: { $oid: 'mockUnprocessedTrip' }, + additions: [], + confidence_threshold: 0.0, + distance: 1.0, + duration: 3.0, + end_fmt_time: '', + end_loc: { type: 'Point', coordinates: [] }, + inferred_labels: [], + key: 'mockUnprocessedTrip', + locations: [], + origin_key: '', + source: '', + start_ts: 0.1, + start_loc: { type: 'Point', coordinates: [] }, +} as unknown as UnprocessedTrip; + +export const mockCompData: ServerResponse = { + phone_data: [ + { + data: { + _id: { $oid: 'mockDataOne' }, + additions: [], + cleaned_section_summary: null, + cleaned_trip: null, + confidence_threshold: -1, + confirmed_trip: null, + distance: 777, + duration: 777, + end_confirmed_place: { + data: mockConfirmedPlaceData, + metadata: mockMetaData, + _id: { $oid: 'endConfirmedPlace' }, + }, + end_fmt_time: '2023-11-01T17:55:20.999397-07:00', + end_loc: { + type: 'Point', + coordinates: [-1, -1], + }, + end_local_dt: null, + end_place: null, + end_ts: -1, + expectation: null, + expected_trip: null, + inferred_labels: [], + inferred_section_summary: { + count: { + CAR: 1, + WALKING: 1, + }, + distance: { + CAR: 222, + WALKING: 222, + }, + duration: { + CAR: 333, + WALKING: 333, + }, + }, + inferred_trip: null, + key: '12345', + locations: [ + { + metadata: mockMetaData, + data: null, + }, + ], + origin_key: '', + raw_trip: null, + sections: [ + { + metadata: mockMetaData, + data: null, + }, + ], + source: 'DwellSegmentationDistFilter', + start_confirmed_place: { + data: mockConfirmedPlaceData, + metadata: mockMetaData, + _id: { $oid: 'startConfirmedPlace' }, + }, + start_fmt_time: '2023-11-01T17:55:20.999397-07:00', + start_loc: { + type: 'Point', + coordinates: [-1, -1], + }, + start_local_dt: null, + start_place: null, + start_ts: 1, + user_input: null, + }, + metadata: mockMetaData, + }, + ], +} as unknown as ServerResponse; + +// Setup for second mockData +let newPhoneData = JSON.parse(JSON.stringify(mockCompData.phone_data[0])); +newPhoneData.data._id.$oid = 'mockDataTwo'; +newPhoneData.metadata = mockMetaDataTwo; +newPhoneData.data.start_confirmed_place.metadata = mockMetaDataTwo; +newPhoneData.data.start_confirmed_place._id.$oid = 'startConfirmedPlaceTwo'; +newPhoneData.data.end_confirmed_place.metadata = mockMetaDataTwo; +newPhoneData.data.end_confirmed_place._id.$oid = 'endConfirmedPlaceTwo'; +export const mockCompDataTwo = { + phone_data: [mockCompData.phone_data[0], newPhoneData], +}; + +export const mockTransitions: Array> = [ + { + data: { + // mock of a startTransition + currstate: '', + transition: 'T_EXITED_GEOFENCE', + ts: 1, + }, + metadata: mockMetaData, + }, + { + data: { + // mock of an endTransition + currstate: '', + transition: 'T_TRIP_ENDED', + ts: 9999, + }, + metadata: mockMetaData, + }, +]; + +const mockFilterLocation: FilteredLocation = { + accuracy: 0.1, + latitude: 1.0, + longitude: -1.0, + ts: 100, +} as FilteredLocation; +let mockFilterLocationTwo = JSON.parse(JSON.stringify(mockFilterLocation)); +mockFilterLocationTwo.ts = 900; +mockFilterLocationTwo.longitude = 200; +mockFilterLocationTwo.longitude = -200; + +export const mockFilterLocations: Array> = [ + { + data: mockFilterLocation, + metadata: mockMetaData, + }, + { + data: mockFilterLocationTwo, + metadata: mockMetaDataTwo, + }, +]; + +export const mockConfigModeOfStudy = { + survey_info: { + 'trip-labels': 'MULTILABEL', + }, + intro: { + mode_studied: 'sample_study', + }, +} as AppConfig; +export const mockConfigNoModeOfStudy = { + survey_info: { + 'trip-labels': 'MULTILABEL', + }, + intro: {}, +} as AppConfig; +export const mockConfigEnketo = { + survey_info: { + 'trip-labels': 'ENKETO', + surveys: { + TripConfirmSurvey: { + compatibleWith: 1.2, + }, + }, + }, + intro: {}, +} as unknown as AppConfig; + +// Used by jest.mocks() to return a various mocked objects. +export const fakeStartTsOne = -14576291; +export const fakeEndTsOne = -13885091; +export const fakeStartTsTwo = 1092844665; +export const fakeEndTsTwo = 1277049465; diff --git a/www/__tests__/LoadMoreButton.test.tsx b/www/__tests__/LoadMoreButton.test.tsx new file mode 100644 index 000000000..b3c9cc956 --- /dev/null +++ b/www/__tests__/LoadMoreButton.test.tsx @@ -0,0 +1,25 @@ +/** + * @jest-environment jsdom + */ +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( {}}>{}); + await waitFor(() => { + expect(screen.getByTestId('load-button')).toBeTruthy(); + }); + }, 15000); + + it('calls onPressFn when clicked', async () => { + const mockFn = jest.fn(); + const { getByTestId } = render({}); + const loadButton = getByTestId('load-button'); + fireEvent.press(loadButton); + await waitFor(() => { + expect(mockFn).toHaveBeenCalled(); + }); + }, 15000); +}); diff --git a/www/__tests__/clientStats.test.ts b/www/__tests__/clientStats.test.ts new file mode 100644 index 000000000..a3a953582 --- /dev/null +++ b/www/__tests__/clientStats.test.ts @@ -0,0 +1,58 @@ +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" +mockGetAppVersion(); +// clientStats.ts uses BEMUserCache to store the stats, so we need to mock that too +mockBEMUserCache(); +const db = window['cordova']?.plugins?.BEMUserCache; + +it('gets the app version', async () => { + const ver = await getAppVersion(); + expect(ver).toEqual('1.2.3'); +}); + +it('stores a client stats reading', async () => { + const reading = { a: 1, b: 2 }; + await addStatReading(statKeys.REMINDER_PREFS, reading); + const storedMessages = await db.getAllMessages('stats/client_time', false); + expect(storedMessages).toContainEqual({ + name: statKeys.REMINDER_PREFS, + ts: expect.any(Number), + reading, + client_app_version: '1.2.3', + client_os_version: '14.0.0', + }); +}); + +it('stores a client stats event', async () => { + await addStatEvent(statKeys.BUTTON_FORCE_SYNC); + const storedMessages = await db.getAllMessages('stats/client_nav_event', false); + expect(storedMessages).toContainEqual({ + name: statKeys.BUTTON_FORCE_SYNC, + ts: expect.any(Number), + reading: null, + client_app_version: '1.2.3', + client_os_version: '14.0.0', + }); +}); + +it('stores a client stats error', async () => { + const errorStr = 'test error'; + await addStatError(statKeys.MISSING_KEYS, errorStr); + const storedMessages = await db.getAllMessages('stats/client_error', false); + expect(storedMessages).toContainEqual({ + name: statKeys.MISSING_KEYS, + ts: expect.any(Number), + reading: errorStr, + client_app_version: '1.2.3', + client_os_version: '14.0.0', + }); +}); diff --git a/www/__tests__/commHelper.test.ts b/www/__tests__/commHelper.test.ts new file mode 100644 index 000000000..d7018abb5 --- /dev/null +++ b/www/__tests__/commHelper.test.ts @@ -0,0 +1,50 @@ +import { mockLogger } from '../__mocks__/globalMocks'; +import { fetchUrlCached } from '../js/services/commHelper'; + +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; + +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 tsBetweenCalls = Date.now(); + 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')); + expect(tsAfterCalls - tsBetweenCalls).toBeLessThan(tsBetweenCalls - tsBeforeCalls); +}); + +/* The following functions from commHelper.ts are not tested because they are just wrappers + around the native functions in BEMServerComm. + If we wanted to test them, we would need to mock the native functions in BEMServerComm. + It would be better to do integration tests that actually call the native functions. + + * - getRawEntries + * - getRawEntriesForLocalDate + * - getPipelineRangeTs + * - getPipelineCompleteTs + * - getMetrics + * - getAggregateData + * - registerUser + * - updateUser + * - getUser + * - putOne +*/ diff --git a/www/__tests__/confirmHelper.test.ts b/www/__tests__/confirmHelper.test.ts new file mode 100644 index 000000000..b95b10372 --- /dev/null +++ b/www/__tests__/confirmHelper.test.ts @@ -0,0 +1,187 @@ +import { mockLogger } from '../__mocks__/globalMocks'; +import * as CommHelper from '../js/services/commHelper'; +import { + baseLabelInputDetails, + getLabelInputDetails, + getLabelOptions, + inferFinalLabels, + labelInputDetailsForTrip, + labelKeyToReadable, + labelKeyToRichMode, + labelOptionByValue, + readableLabelToKey, + verifiabilityForTrip, +} from '../js/survey/multilabel/confirmHelper'; + +import initializedI18next from '../js/i18nextInit'; +import { CompositeTrip, UserInputEntry } from '../js/types/diaryTypes'; +import { UserInputMap } from '../js/diary/LabelTabContext'; +window['i18next'] = initializedI18next; +mockLogger(); + +const fakeAppConfig = { + label_options: 'json/label-options.json.sample', +}; +const fakeAppConfigWithModeOfStudy = { + ...fakeAppConfig, + intro: { + mode_studied: 'walk', + }, +}; +const fakeDefaultLabelOptions = { + MODE: [ + { value: 'walk', baseMode: 'WALKING', met_equivalent: 'WALKING', kgCo2PerKm: 0 }, + { value: 'bike', baseMode: 'BICYCLING', met_equivalent: 'BICYCLING', kgCo2PerKm: 0 }, + ], + PURPOSE: [{ value: 'home' }, { value: 'work' }], + REPLACED_MODE: [{ value: 'no_travel' }, { value: 'walk' }, { value: 'bike' }], + translations: { + en: { + walk: 'Walk', + bike: 'Regular Bike', + no_travel: 'No travel', + home: 'Home', + work: 'To Work', + }, + }, +}; +const fakeInputs = { + MODE: [ + { data: { label: 'walk', start_ts: 1245, end_ts: 5678 } }, + { data: { label: 'bike', start_ts: 1245, end_ts: 5678 } }, + ], + PURPOSE: [ + { data: { label: 'home', start_ts: 1245, end_ts: 5678 } }, + { data: { label: 'work', start_ts: 1245, end_ts: 5678 } }, + ], +}; + +jest.mock('../js/services/commHelper', () => ({ + ...jest.requireActual('../js/services/commHelper'), + fetchUrlCached: jest + .fn() + .mockReturnValue(Promise.resolve(JSON.stringify(fakeDefaultLabelOptions))), +})); + +describe('confirmHelper', () => { + it('returns labelOptions given an appConfig', async () => { + const labelOptions = await getLabelOptions(fakeAppConfig); + expect(labelOptions).toBeTruthy(); + expect(labelOptions.MODE[0].text).toEqual('Walk'); // translation is filled in + }); + + it('returns base labelInputDetails for a labelUserInput which does not have mode of study', () => { + const fakeLabelUserInput = { + MODE: fakeInputs.MODE[1], + PURPOSE: fakeInputs.PURPOSE[0], + }; + const labelInputDetails = labelInputDetailsForTrip( + fakeLabelUserInput, + fakeAppConfigWithModeOfStudy, + ); + expect(labelInputDetails).toEqual(baseLabelInputDetails); + }); + + it('returns full labelInputDetails for a labelUserInput which has the mode of study', () => { + const fakeLabelUserInput = { + MODE: fakeInputs.MODE[0], // 'walk' is mode of study + PURPOSE: fakeInputs.PURPOSE[0], + }; + const labelInputDetails = labelInputDetailsForTrip( + fakeLabelUserInput, + fakeAppConfigWithModeOfStudy, + ); + const fullLabelInputDetails = getLabelInputDetails(fakeAppConfigWithModeOfStudy); + expect(labelInputDetails).toEqual(fullLabelInputDetails); + }); + + it(`converts 'other' text to a label key`, () => { + const mode1 = readableLabelToKey(`Scooby Doo Mystery Machine `); + expect(mode1).toEqual('scooby_doo_mystery_machine'); // trailing space is trimmed + const mode2 = readableLabelToKey(`My niece's tricycle . `); + expect(mode2).toEqual(`my_niece's_tricycle_.`); // apostrophe and period are preserved + const purpose1 = readableLabelToKey(`Going to the store to buy 12 eggs.`); + expect(purpose1).toEqual('going_to_the_store_to_buy_12_eggs.'); // numbers are preserved + }); + + it(`converts keys to readable labels`, () => { + const mode1 = labelKeyToReadable(`scooby_doo_mystery_machine`); + expect(mode1).toEqual(`Scooby Doo Mystery Machine`); + const mode2 = labelKeyToReadable(`my_niece's_tricycle_.`); + expect(mode2).toEqual(`My Niece's Tricycle .`); + const purpose1 = labelKeyToReadable(`going_to_the_store_to_buy_12_eggs.`); + expect(purpose1).toEqual(`Going To The Store To Buy 12 Eggs.`); + }); + + it('looks up a rich mode from a label key, or humanizes the label key if there is no rich mode', () => { + const key = 'walk'; + const richMode = labelKeyToRichMode(key); + expect(richMode).toEqual('Walk'); + const key2 = 'scooby_doo_mystery_machine'; + const readableMode = labelKeyToRichMode(key2); + expect(readableMode).toEqual('Scooby Doo Mystery Machine'); + }); + + /* BEGIN: tests for inferences, which are loosely based on the server-side tests from + e-mission-server -> emission/tests/storageTests/TestTripQueries.py -> testExpandFinalLabels() */ + + it('has no final label for a trip with no user labels or inferred labels', () => { + const fakeTrip = {} as CompositeTrip; + const fakeUserInput = {}; + expect(inferFinalLabels(fakeTrip, fakeUserInput)).toEqual({}); + expect(verifiabilityForTrip(fakeTrip, fakeUserInput)).toEqual('cannot-verify'); + }); + + it('returns a final inference for a trip no user labels and all high-confidence inferred labels', () => { + const fakeTrip = { + inferred_labels: [{ labels: { mode_confirm: 'walk', purpose_confirm: 'exercise' }, p: 0.9 }], + } as CompositeTrip; + const fakeUserInput = {}; + const final = inferFinalLabels(fakeTrip, fakeUserInput); + expect(final.MODE?.value).toEqual('walk'); + expect(final.PURPOSE?.value).toEqual('exercise'); + expect(verifiabilityForTrip(fakeTrip, fakeUserInput)).toEqual('can-verify'); + }); + + it('gives no final inference when there are user labels and no inferred labels', () => { + const fakeTrip = {} as CompositeTrip; + const fakeUserInput: UserInputMap = { + MODE: { data: { label: 'bike' } } as UserInputEntry, + PURPOSE: { data: { label: 'shopping' } } as UserInputEntry, + }; + const final = inferFinalLabels(fakeTrip, fakeUserInput); + expect(final.MODE?.value).toBeUndefined(); + expect(final.PURPOSE?.value).toBeUndefined(); + expect(verifiabilityForTrip(fakeTrip, fakeUserInput)).toEqual('already-verified'); + }); + + it('still gives no final inference when there are user labels and high-confidence inferred labels', () => { + const fakeTrip = { + inferred_labels: [{ labels: { mode_confirm: 'walk', purpose_confirm: 'exercise' }, p: 0.9 }], + } as CompositeTrip; + const fakeUserInput = { + MODE: { data: { label: 'bike' } } as UserInputEntry, + PURPOSE: { data: { label: 'shopping' } } as UserInputEntry, + }; + const final = inferFinalLabels(fakeTrip, fakeUserInput); + expect(final.MODE?.value).toBeUndefined(); + expect(final.PURPOSE?.value).toBeUndefined(); + expect(verifiabilityForTrip(fakeTrip, fakeUserInput)).toEqual('already-verified'); + }); + + it('mixes user input labels with mixed-confidence inferred labels', () => { + const fakeTrip = { + inferred_labels: [ + { labels: { mode_confirm: 'bike', purpose_confirm: 'shopping' }, p: 0.1 }, + { labels: { mode_confirm: 'walk', purpose_confirm: 'exercise' }, p: 0.9 }, + ], + } as CompositeTrip; + const fakeUserInput = { + MODE: { data: { label: 'bike' } } as UserInputEntry, + }; + const final = inferFinalLabels(fakeTrip, fakeUserInput); + expect(final.MODE?.value).toEqual('bike'); + expect(final.PURPOSE?.value).toEqual('shopping'); + expect(verifiabilityForTrip(fakeTrip, fakeUserInput)).toEqual('can-verify'); + }); +}); diff --git a/www/__tests__/customEventHandler.test.ts b/www/__tests__/customEventHandler.test.ts new file mode 100644 index 000000000..7a5c09539 --- /dev/null +++ b/www/__tests__/customEventHandler.test.ts @@ -0,0 +1,24 @@ +import { publish, subscribe, unsubscribe } from '../js/customEventHandler'; +import { mockLogger } from '../__mocks__/globalMocks'; + +mockLogger(); + +it('subscribes and publishes to an event', () => { + const listener = jest.fn(); + subscribe('test', listener); + publish('test', 'test data'); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'test', + detail: 'test data', + }), + ); +}); + +it('can unsubscribe', () => { + const listener = jest.fn(); + subscribe('test', listener); + unsubscribe('test', listener); + publish('test', 'test data'); + expect(listener).not.toHaveBeenCalled(); +}); diff --git a/www/__tests__/customMetricsHelper.test.ts b/www/__tests__/customMetricsHelper.test.ts new file mode 100644 index 000000000..1c56d7a54 --- /dev/null +++ b/www/__tests__/customMetricsHelper.test.ts @@ -0,0 +1,64 @@ +import { getConfig } from '../js/config/dynamicConfig'; +import { + _test_clearCustomMetrics, + getCustomFootprint, + getCustomMETs, + initCustomDatasetHelper, +} from '../js/metrics/customMetricsHelper'; +import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import fakeLabels from '../__mocks__/fakeLabels.json'; +import fakeConfig from '../__mocks__/fakeConfig.json'; + +mockBEMUserCache(fakeConfig); +mockLogger(); + +beforeEach(() => { + _test_clearCustomMetrics(); +}); + +global.fetch = (url: string) => + new Promise((rs, rj) => { + setTimeout(() => + rs({ + text: () => + new Promise((rs, rj) => { + let myJSON = JSON.stringify(fakeLabels); + setTimeout(() => rs(myJSON), 100); + }), + }), + ); + }) as any; + +it('has no footprint or mets before initialized', () => { + expect(getCustomFootprint()).toBeUndefined(); + expect(getCustomMETs()).toBeUndefined(); +}); + +it('gets the custom mets', async () => { + const appConfig = await getConfig(); + await initCustomDatasetHelper(appConfig); + //expecting the keys from fakeLabels.json NOT metrics/metDataset.ts + expect(getCustomMETs()).toMatchObject({ + walk: expect.any(Object), + bike: expect.any(Object), + bikeshare: expect.any(Object), + 'e-bike': expect.any(Object), + scootershare: expect.any(Object), + drove_alone: expect.any(Object), + }); +}); + +it('gets the custom footprint', async () => { + const appConfig = await getConfig(); + await initCustomDatasetHelper(appConfig); + //numbers from fakeLabels.json + expect(getCustomFootprint()).toMatchObject({ + walk: 0, + bike: 0, + bikeshare: 0, + 'e-bike': 0.00728, + scootershare: 0.00894, + drove_alone: 0.22031, + }); +}); diff --git a/www/__tests__/customURL.test.ts b/www/__tests__/customURL.test.ts new file mode 100644 index 000000000..c06345679 --- /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(); + }); +}); diff --git a/www/__tests__/diaryHelper.test.ts b/www/__tests__/diaryHelper.test.ts index 822b19bba..0eb4a8628 100644 --- a/www/__tests__/diaryHelper.test.ts +++ b/www/__tests__/diaryHelper.test.ts @@ -1,63 +1,100 @@ -import { getFormattedDate, isMultiDay, getFormattedDateAbbr, getFormattedTimeRange, getDetectedModes, getBaseModeByKey, modeColors } from "../js/diary/diaryHelper"; +import { + getFormattedDate, + isMultiDay, + getFormattedDateAbbr, + getFormattedTimeRange, + getDetectedModes, + getBaseModeByKey, + modeColors, +} from '../js/diary/diaryHelper'; + +import initializedI18next from '../js/i18nextInit'; +window['i18next'] = initializedI18next; 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 } -]}; +/* fake trips with 'distance' in their section summaries + ('count' and 'duration' are not used bygetDetectedModes) */ +let myFakeTrip = { + distance: 6729.0444371031606, + cleaned_section_summary: { + // count: {...} + // duration: {...} + distance: { + BICYCLING: 6013.73657416706, + WALKING: 715.3078629361006, + }, + }, +} as any; + +let myFakeTrip2 = { + ...myFakeTrip, + inferred_section_summary: { + // count: {...} + // duration: {...} + distance: { + BICYCLING: 6729.0444371031606, + }, + }, +}; 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 -}) + expect(getDetectedModes({} as any)).toEqual([]); // empty trip, no sections, no modes +}); diff --git a/www/__tests__/dynamicConfig.test.ts b/www/__tests__/dynamicConfig.test.ts new file mode 100644 index 000000000..12fec433b --- /dev/null +++ b/www/__tests__/dynamicConfig.test.ts @@ -0,0 +1,134 @@ +import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import { mockAlert, mockLogger } from '../__mocks__/globalMocks'; +import { getConfig, initByUser } from '../js/config/dynamicConfig'; + +import initializedI18next from '../js/i18nextInit'; +import { storageClear } from '../js/plugin/storage'; +window['i18next'] = initializedI18next; + +mockLogger(); +mockAlert(); +mockBEMUserCache(); + +beforeEach(() => { + // clear all storage and the config document + storageClear({ local: true, native: true }); + window['cordova'].plugins.BEMUserCache.putRWDocument('config/app_ui_config', {}); +}); + +const nrelCommuteConfig = { + version: 1, + server: { + connectUrl: 'https://nrel-commute-openpath.nrel.gov/api/', + aggregate_call_auth: 'user_only', + }, + // ... +}; + +const denverCasrConfig = { + version: 1, + server: { + connectUrl: 'https://denver-casr-openpath.nrel.gov/api/', + aggregate_call_auth: 'user_only', + }, + opcode: { + autogen: true, + subgroups: [ + 'test', + 'qualified-cargo', + 'qualified-regular', + 'standard-cargo', + 'standard-regular', + ], + }, + // ... +}; + +global.fetch = (url: string) => { + return new Promise((rs, rj) => { + if (url.includes('nrel-commute.nrel-op.json')) { + rs({ + ok: true, + json: () => new Promise((rs, rj) => rs(nrelCommuteConfig)), + }); + } else if (url.includes('denver-casr.nrel-op.json')) { + rs({ + ok: true, + json: () => new Promise((rs, rj) => rs(denverCasrConfig)), + }); + } else { + rj(new Error('404 while fetching ' + url)); + } + }) as any; +}; + +describe('dynamicConfig', () => { + const fakeStudyName = 'gotham-city-transit'; + const validStudyNrelCommute = 'nrel-commute'; + const validStudyDenverCasr = 'denver-casr'; + + describe('getConfig', () => { + it('should resolve with null since no config is set yet', async () => { + await expect(getConfig()).resolves.toBeNull(); + }); + it('should resolve with a valid config once initByUser is called for an nrel-commute token', async () => { + const validToken = `nrelop_${validStudyNrelCommute}_user1`; + await initByUser({ token: validToken }); + const config = await getConfig(); + expect(config.server.connectUrl).toBe('https://nrel-commute-openpath.nrel.gov/api/'); + expect(config.joined).toEqual({ + opcode: validToken, + study_name: validStudyNrelCommute, + subgroup: undefined, + }); + }); + + it('should resolve with a valid config once initByUser is called for a denver-casr token', async () => { + const validToken = `nrelop_${validStudyDenverCasr}_test_user1`; + await initByUser({ token: validToken }); + const config = await getConfig(); + expect(config.server.connectUrl).toBe('https://denver-casr-openpath.nrel.gov/api/'); + expect(config.joined).toEqual({ + opcode: validToken, + study_name: validStudyDenverCasr, + subgroup: 'test', + }); + }); + }); + + describe('initByUser', () => { + // fake study (gotham-city-transit) + it('should error if the study is nonexistent', async () => { + const fakeBatmanToken = `nrelop_${fakeStudyName}_batman`; + await expect(initByUser({ token: fakeBatmanToken })).rejects.toThrow(); + }); + + // real study without subgroups (nrel-commute) + it('should error if the study exists but the token is invalid format', async () => { + const badToken1 = validStudyNrelCommute; // doesn't start with nrelop_ + await expect(initByUser({ token: badToken1 })).rejects.toThrow(); + const badToken2 = `nrelop_${validStudyNrelCommute}`; // doesn't have enough _ + await expect(initByUser({ token: badToken2 })).rejects.toThrow(); + const badToken3 = `nrelop_${validStudyNrelCommute}_`; // doesn't have user code after last _ + await expect(initByUser({ token: badToken3 })).rejects.toThrow(); + }); + it('should return true after successfully storing the config for a valid token', async () => { + const validToken = `nrelop_${validStudyNrelCommute}_user2`; + await expect(initByUser({ token: validToken })).resolves.toBe(true); + }); + + // real study with subgroups (denver-casr) + it('should error if the study uses subgroups but the token has no subgroup', async () => { + const tokenWithoutSubgroup = `nrelop_${validStudyDenverCasr}_user2`; + await expect(initByUser({ token: tokenWithoutSubgroup })).rejects.toThrow(); + }); + it('should error if the study uses subgroups and the token is invalid format', async () => { + const badToken1 = `nrelop_${validStudyDenverCasr}_test_`; // doesn't have user code after last _ + await expect(initByUser({ token: badToken1 })).rejects.toThrow(); + }); + it('should return true after successfully storing the config for a valid token with subgroup', async () => { + const validToken = `nrelop_${validStudyDenverCasr}_test_user2`; + await expect(initByUser({ token: validToken })).resolves.toBe(true); + }); + }); +}); diff --git a/www/__tests__/enketoHelper.test.ts b/www/__tests__/enketoHelper.test.ts new file mode 100644 index 000000000..aedd7deec --- /dev/null +++ b/www/__tests__/enketoHelper.test.ts @@ -0,0 +1,342 @@ +import { + getInstanceStr, + filterByNameAndVersion, + resolveTimestamps, + resolveLabel, + loadPreviousResponseForSurvey, + saveResponse, + EnketoUserInputEntry, +} from '../js/survey/enketo/enketoHelper'; +import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import { getConfig, _test_resetStoredConfig } from '../../www/js/config/dynamicConfig'; +import fakeConfig from '../__mocks__/fakeConfig.json'; + +import initializedI18next from '../js/i18nextInit'; +import { CompositeTrip } from '../js/types/diaryTypes'; +import { AppConfig } from '../js/types/appConfigTypes'; +window['i18next'] = initializedI18next; + +mockBEMUserCache(fakeConfig); +mockLogger(); + +global.URL = require('url').URL; +global.Blob = require('node:buffer').Blob; + +beforeEach(() => { + _test_resetStoredConfig(); +}); + +it('gets the survey config', async () => { + //this is aimed at testing my mock of the config + //mocked getDocument for the case of getting the config + let config = await getConfig(); + let mockSurveys = { + TimeUseSurvey: { + compatibleWith: 1, + formPath: + 'https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json', + labelTemplate: { + en: '{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }', + es: '{ erea, plural, =0 {} other {# Empleo/EducaciĆ³n, } }{ da, plural, =0 {} other {# Actividades domesticas, }}', + }, + labelVars: { + da: { key: 'Domestic_activities', type: 'length' }, + erea: { key: 'Employment_related_a_Education_activities', type: 'length' }, + }, + version: 9, + }, + }; + expect(config.survey_info.surveys).toMatchObject(mockSurveys); +}); + +it('gets the model response, if avaliable, or returns null', () => { + const xmlModel = + ''; + const filled = + '2016-07-2517:24:32.928-06:002016-07-2517:30:31.000-06:00'; + const opts = { prefilledSurveyResponse: filled }; + const opts2 = { + prefillFields: { + Start_date: '2016-07-25', + Start_time: '17:24:32.928-06:00', + End_date: '2016-07-25', + End_time: '17:30:31.000-06:00', + }, + }; + + //if no xmlModel, returns null + expect(getInstanceStr('', opts)).toBe(null); + //if there is a prefilled survey, return it + expect(getInstanceStr(xmlModel, opts)).toBe(filled); + //if there is a model and fields, return prefilled + expect(getInstanceStr(xmlModel, opts2)).toBe(filled); + //if none of those things, also return null + expect(getInstanceStr(xmlModel, {})).toBe(null); +}); + +//resolve timestamps +it('resolves the timestamps', () => { + const xmlParser = new window.DOMParser(); + const timelineEntry = { + end_local_dt: { timezone: 'America/Los_Angeles' }, + start_ts: 1469492672.928242, + end_ts: 1469493031, + } as CompositeTrip; + + //missing data returns null + const missingData = + ' 2016-08-28 2016-07-25 17:30:31.000-06:00 '; + const missDataDoc = xmlParser.parseFromString(missingData, 'text/html'); + expect(resolveTimestamps(missDataDoc, timelineEntry, () => {})).toBeNull(); + //bad time returns undefined + const badTimes = + ' 2016-08-28 2016-07-25 17:32:32.928-06:00 17:30:31.000-06:00 '; + const badTimeDoc = xmlParser.parseFromString(badTimes, 'text/xml'); + expect(resolveTimestamps(badTimeDoc, timelineEntry, () => {})).toBeUndefined(); + //if within a minute, timelineEntry timestamps + const timeEntry = + ' 2016-07-25 2016-07-25 17:24:32.928-06:00 17:30:31.000-06:00 '; + const xmlDoc1 = xmlParser.parseFromString(timeEntry, 'text/xml'); + expect(resolveTimestamps(xmlDoc1, timelineEntry, () => {})).toMatchObject({ + start_ts: 1469492672.928242, + end_ts: 1469493031, + }); + // else survey timestamps + const timeSurvey = + ' 2016-07-25 2016-07-25 17:22:33.928-06:00 17:33:33.000-06:00 '; + const xmlDoc2 = xmlParser.parseFromString(timeSurvey, 'text/xml'); + expect(resolveTimestamps(xmlDoc2, timelineEntry, () => {})).toMatchObject({ + start_ts: 1469492553.928, + end_ts: 1469493213, + }); +}); + +//resolve label +it('resolves the label, normal case', async () => { + const xmlParser = new window.DOMParser(); + const xmlString = ' option_1 '; + const xmlDoc = xmlParser.parseFromString(xmlString, 'text/html'); + const xmlString2 = + ' option_1 option_3 '; + const xmlDoc2 = xmlParser.parseFromString(xmlString2, 'text/xml'); + + //have a custom survey label function TODO: we currently don't have custome label functions, but should test when we do + //no custom function, fallback to UseLabelTemplate (standard case) + mockBEMUserCache(fakeConfig); + expect(await resolveLabel('TimeUseSurvey', xmlDoc)).toBe('3 Domestic'); + expect(await resolveLabel('TimeUseSurvey', xmlDoc2)).toBe('3 Employment/Education, 3 Domestic'); +}); + +it('resolves the label, if no template, returns "Answered"', async () => { + const xmlParser = new window.DOMParser(); + const xmlString = ' option_1 '; + const xmlDoc = xmlParser.parseFromString(xmlString, 'text/html'); + const xmlString2 = + ' option_1 option_3 '; + const xmlDoc2 = xmlParser.parseFromString(xmlString2, 'text/xml'); + + const noTemplate = { + survey_info: { + surveys: { + TimeUseSurvey: { + compatibleWith: 1, + formPath: + 'https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json', + labelVars: { + da: { + key: 'Domestic_activities', + type: 'length', + }, + erea: { + key: 'Employment_related_a_Education_activities', + type: 'length', + }, + }, + version: 9, + }, + }, + 'trip-labels': 'ENKETO', + }, + }; + mockBEMUserCache(noTemplate); + expect(await resolveLabel('TimeUseSurvey', xmlDoc)).toBe('Answered'); + expect(await resolveLabel('TimeUseSurvey', xmlDoc2)).toBe('Answered'); +}); + +it('resolves the label, if no labelVars, returns template', async () => { + const xmlParser = new window.DOMParser(); + const xmlString = ' option_1 '; + const xmlDoc = xmlParser.parseFromString(xmlString, 'text/html'); + const xmlString2 = + ' option_1 option_3 '; + const xmlDoc2 = xmlParser.parseFromString(xmlString2, 'text/xml'); + + const noLabels = { + survey_info: { + surveys: { + TimeUseSurvey: { + compatibleWith: 1, + formPath: + 'https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json', + labelTemplate: { + en: '{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }', + es: '{ erea, plural, =0 {} other {# Empleo/EducaciĆ³n, } }{ da, plural, =0 {} other {# Actividades domesticas, }}', + }, + version: 9, + }, + }, + 'trip-labels': 'ENKETO', + }, + }; + mockBEMUserCache(noLabels); + expect(await resolveLabel('TimeUseSurvey', xmlDoc)).toBe( + '{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }', + ); + expect(await resolveLabel('TimeUseSurvey', xmlDoc2)).toBe( + '{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }', + ); +}); + +/** + * @param surveyName the name of the survey (e.g. "TimeUseSurvey") + * @param enketoForm the Form object from enketo-core that contains this survey + * @param appConfig the dynamic config file for the app + * @param opts object with SurveyOptions like 'timelineEntry' or 'dataKey' + * @returns Promise of the saved result, or an Error if there was a problem + */ +// export function saveResponse(surveyName: string, enketoForm: Form, appConfig, opts: SurveyOptions) { +it('gets the saved result or throws an error', async () => { + const surveyName = 'TimeUseSurvey'; + const form = { + getDataStr: () => { + return '2023-10-13T15:05:48.890-06:002023-10-13T15:05:48.892-06:002016-07-2517:24:32.928-06:002016-07-2517:30:31.000-06:00personal_care_activitiesdoing_sportuuid:dc16c287-08b2-4435-95aa-e4d7838b4225'; + }, + }; + //the start time listed is after the end time listed + const badForm = { + getDataStr: () => { + return '2023-10-13T15:05:48.890-06:002023-10-13T15:05:48.892-06:002016-08-2517:24:32.928-06:002016-07-2517:30:31.000-06:00personal_care_activitiesdoing_sportuuid:dc16c287-08b2-4435-95aa-e4d7838b4225'; + }, + }; + const config = { + survey_info: { + surveys: { + TimeUseSurvey: { + compatibleWith: 1, + formPath: + 'https://raw.githubusercontent.com/sebastianbarry/nrel-openpath-deploy-configs/surveys-info-and-surveys-data/survey-resources/data-json/time-use-survey-form-v9.json', + labelTemplate: { + en: '{ erea, plural, =0 {} other {# Employment/Education, } }{ da, plural, =0 {} other {# Domestic, } }', + es: '{ erea, plural, =0 {} other {# Empleo/EducaciĆ³n, } }{ da, plural, =0 {} other {# Actividades domesticas, }}', + }, + labelVars: { + da: { key: 'Domestic_activities', type: 'length' }, + erea: { key: 'Employment_related_a_Education_activities', type: 'length' }, + }, + version: 9, + }, + }, + }, + } as unknown as AppConfig; + const opts = { + timelineEntry: { + end_local_dt: { timezone: 'America/Los_Angeles' }, + start_ts: 1469492672.928242, + end_ts: 1469493031, + } as CompositeTrip, + }; + + console.log(config); + expect(saveResponse(surveyName, form, config, opts)).resolves.toMatchObject({ + label: '1 Personal Care', + name: 'TimeUseSurvey', + }); + expect(async () => await saveResponse(surveyName, badForm, config, opts)).rejects.toEqual( + 'The times you entered are invalid. Please ensure that the start time is before the end time.', + ); +}); + +/* + * 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. + */ +it('loads the previous response to a given survey', () => { + expect(loadPreviousResponseForSurvey('manual/demographic_survey')).resolves.toMatchObject({ + data: 'completed', + time: '01/01/2001', + }); +}); + +/** + * filterByNameAndVersion filter the survey responses by survey name and their version. + * The version for filtering is specified in enketo survey `compatibleWith` config. + * The stored survey response version must be greater than or equal to `compatibleWith` to be included. + */ +it('filters the survey responses by their name and version', () => { + //no response -> no filtered responses + expect(filterByNameAndVersion('TimeUseSurvey', [], fakeConfig)).toStrictEqual([]); + + const response = [ + { + data: { + label: 'Activity', //display label (this value is use for displaying on the button) + ts: 100000000, //the timestamp at which the survey was filled out (in seconds) + fmt_time: '12:36', //the formatted timestamp at which the survey was filled out + name: 'TimeUseSurvey', //survey name + version: 1, //survey version + xmlResponse: '', //survey response XML string + jsonDocResponse: { this: 'is my json object' }, //survey response JSON object + }, + metadata: {}, + }, + ]; + + //one response -> that response + expect(filterByNameAndVersion('TimeUseSurvey', response, fakeConfig)).toStrictEqual(response); + + const responses = [ + { + data: { + label: 'Activity', //display label (this value is use for displaying on the button) + ts: 100000000, //the timestamp at which the survey was filled out (in seconds) + fmt_time: '12:36', //the formatted timestamp at which the survey was filled out + name: 'TimeUseSurvey', //survey name + version: 1, //survey version + xmlResponse: '', //survey response XML string + jsonDocResponse: { this: 'is my json object' }, //survey response JSON object + }, + metadata: {} as any, + }, + { + data: { + label: 'Activity', //display label (this value is use for displaying on the button) + ts: 100000000, //the timestamp at which the survey was filled out (in seconds) + fmt_time: '12:36', //the formatted timestamp at which the survey was filled out + name: 'OtherSurvey', //survey name + version: 1, //survey version + xmlResponse: '', //survey response XML string + jsonDocResponse: { this: 'is my json object' }, //survey response JSON object + }, + metadata: {} as any, + }, + { + data: { + label: 'Activity', //display label (this value is use for displaying on the button) + ts: 100000000, //the timestamp at which the survey was filled out (in seconds) + fmt_time: '12:39', //the formatted timestamp at which the survey was filled out + name: 'TimeUseSurvey', //survey name + version: 0.5, //survey version + xmlResponse: '', //survey response XML string + jsonDocResponse: { this: 'is my json object' }, //survey response JSON object + }, + metadata: {}, + }, + ]; + + //several responses -> only the one that has a name match + expect(filterByNameAndVersion('TimeUseSurvey', responses, fakeConfig)).toStrictEqual(response); +}); diff --git a/www/__tests__/footprintHelper.test.ts b/www/__tests__/footprintHelper.test.ts new file mode 100644 index 000000000..eed592a83 --- /dev/null +++ b/www/__tests__/footprintHelper.test.ts @@ -0,0 +1,89 @@ +import { + _test_clearCustomMetrics, + initCustomDatasetHelper, +} from '../js/metrics/customMetricsHelper'; +import { + clearHighestFootprint, + getFootprintForMetrics, + getHighestFootprint, + getHighestFootprintForDistance, +} from '../js/metrics/footprintHelper'; +import { getConfig } from '../js/config/dynamicConfig'; +import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import fakeLabels from '../__mocks__/fakeLabels.json'; +import fakeConfig from '../__mocks__/fakeConfig.json'; + +mockBEMUserCache(fakeConfig); +mockLogger(); + +global.fetch = (url: string) => + new Promise((rs, rj) => { + setTimeout(() => + rs({ + text: () => + new Promise((rs, rj) => { + let myJSON = JSON.stringify(fakeLabels); + setTimeout(() => rs(myJSON), 100); + }), + }), + ); + }) as any; + +beforeEach(() => { + clearHighestFootprint(); + _test_clearCustomMetrics(); +}); + +const custom_metrics = [ + { key: 'ON_FOOT', values: 3000 }, //hits fallback under custom paradigm + { key: 'bike', values: 6500 }, + { key: 'drove_alone', values: 10000 }, + { key: 'scootershare', values: 25000 }, + { key: 'unicycle', values: 5000 }, +]; + +/* + 3*0 + 6.5*0 + 10*0.22031 + 25*0.00894 + 5*0 + 0 + 0 + 2.2031 + 0.2235 + 0 + 2.4266 +*/ +it('gets footprint for metrics (custom, fallback 0)', async () => { + const appConfig = await getConfig(); + await initCustomDatasetHelper(appConfig); + expect(getFootprintForMetrics(custom_metrics, 0)).toBe(2.4266); +}); + +/* + 3*0.1 + 6.5*0 + 10*0.22031 + 25*0.00894 + 5*0.1 + 0.3 + 0 + 2.2031 + 0.2235 + 0.5 + 0.3 2.4266 + 0.5 +*/ +it('gets footprint for metrics (custom, fallback 0.1)', async () => { + const appConfig = await getConfig(); + await initCustomDatasetHelper(appConfig); + expect(getFootprintForMetrics(custom_metrics, 0.1)).toBe(3.2266); +}); + +//expects TAXI from the fake labels +it('gets the highest footprint from the dataset, custom', async () => { + const appConfig = await getConfig(); + await initCustomDatasetHelper(appConfig); + expect(getHighestFootprint()).toBe(0.30741); +}); + +/* + TAXI co2/km * meters/1000 +*/ +it('gets the highest footprint for distance, custom', async () => { + const appConfig = await getConfig(); + await initCustomDatasetHelper(appConfig); + expect(getHighestFootprintForDistance(12345)).toBe(0.30741 * (12345 / 1000)); +}); + +it('errors out if not initialized', () => { + const t = () => { + getFootprintForMetrics(custom_metrics, 0); + }; + expect(t).toThrow(Error); +}); diff --git a/www/__tests__/inputMatcher.test.ts b/www/__tests__/inputMatcher.test.ts new file mode 100644 index 000000000..062951b35 --- /dev/null +++ b/www/__tests__/inputMatcher.test.ts @@ -0,0 +1,487 @@ +import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import { unprocessedLabels, updateLocalUnprocessedInputs } from '../js/diary/timelineHelper'; +import * as logger from '../js/plugin/logger'; +import { EnketoUserInputEntry } from '../js/survey/enketo/enketoHelper'; +import { + fmtTs, + printUserInput, + validUserInputForDraftTrip, + validUserInputForTimelineEntry, + getNotDeletedCandidates, + getUserInputForTimelineEntry, + getAdditionsForTimelineEntry, + getUniqueEntries, + mapInputsToTimelineEntries, +} from '../js/survey/inputMatcher'; +import { AppConfig } from '../js/types/appConfigTypes'; +import { CompositeTrip, TimelineEntry, UserInputEntry } from '../js/types/diaryTypes'; + +mockLogger(); +mockBEMUserCache(); + +describe('input-matcher', () => { + let userTrip: UserInputEntry; + let trip: TimelineEntry; + let nextTrip: TimelineEntry; + + beforeEach(() => { + /* + Create a valid userTrip and trip object before each test case. + The trip data is from the 'real_examples' data (shankari_2015-07-22) on the server. + For some test cases, I need to generate fake data, such as labels, keys, and origin_keys. + In such cases, I referred to 'TestUserInputFakeData.py' on the server. + */ + userTrip = { + data: { + end_ts: 1437604764, + start_ts: 1437601247, + label: 'FOO', + status: 'ACTIVE', + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695921991, + platform: 'ios', + read_ts: 0, + key: 'manual/mode_confirm', + }, + key: 'manual/place', + } as UserInputEntry; + trip = { + key: 'FOO', + origin_key: 'FOO', + start_ts: 1437601000, + end_ts: 1437605000, + enter_ts: 1437605000, + exit_ts: 1437605000, + duration: 100, + } as unknown as CompositeTrip; + nextTrip = { + key: 'BAR', + origin_key: 'BAR', + start_ts: 1437606000, + end_ts: 1437607000, + enter_ts: 1437607000, + exit_ts: 1437607000, + duration: 100, + } as unknown as CompositeTrip; + // mock Logger + window['Logger'] = { log: console.log }; + }); + + it('tests fmtTs with valid input', () => { + const pstTime = fmtTs(1437601247.8459613, 'America/Los_Angeles'); + const estTime = fmtTs(1437601247.8459613, 'America/New_York'); + + // Check if it contains correct year-mm-dd hr:mm + expect(pstTime).toContain('2015-07-22T14:40'); + expect(estTime).toContain('2015-07-22T17:40'); + }); + + it('tests fmtTs with invalid input', () => { + const formattedTime = fmtTs(0, ''); + expect(formattedTime).toBeFalsy(); + }); + + it('tests printUserInput prints the trip log correctly', () => { + const userTripLog = printUserInput(userTrip); + expect(userTripLog).toContain('1437604764'); + expect(userTripLog).toContain('1437601247'); + expect(userTripLog).toContain('FOO'); + }); + + it('tests validUserInputForDraftTrip with valid trip input', () => { + const validTrp = { + end_ts: 1437604764, + start_ts: 1437601247, + } as CompositeTrip; + const validUserInput = validUserInputForDraftTrip(validTrp, userTrip, false); + expect(validUserInput).toBeTruthy(); + }); + + it('tests validUserInputForDraftTrip with invalid trip input', () => { + const invalidTrip = { + end_ts: 0, + start_ts: 0, + } as CompositeTrip; + const invalidUserInput = validUserInputForDraftTrip(invalidTrip, userTrip, false); + expect(invalidUserInput).toBeFalsy(); + }); + + it('tests validUserInputForTimelineEntry with valid trip object', () => { + // we need valid key and origin_key for validUserInputForTimelineEntry test + trip['key'] = 'analysis/confirmed_place'; + trip['origin_key'] = 'analysis/confirmed_place'; + const validTimelineEntry = validUserInputForTimelineEntry(trip, nextTrip, userTrip, false); + expect(validTimelineEntry).toBeTruthy(); + }); + + it('tests validUserInputForTimelineEntry with tlEntry with invalid key and origin_key', () => { + const invalidTlEntry = trip; + const invalidTimelineEntry = validUserInputForTimelineEntry( + invalidTlEntry, + null, + userTrip, + false, + ); + expect(invalidTimelineEntry).toBeFalsy(); + }); + + it('tests validUserInputForTimelineEntry with tlEntry with invalie start & end time', () => { + const invalidTlEntry: TimelineEntry = { + key: 'analysis/confirmed_place', + origin_key: 'analysis/confirmed_place', + start_ts: 1, + end_ts: 1, + enter_ts: 1, + exit_ts: 1, + duration: 1, + } as unknown as TimelineEntry; + const invalidTimelineEntry = validUserInputForTimelineEntry( + invalidTlEntry, + null, + userTrip, + false, + ); + expect(invalidTimelineEntry).toBeFalsy(); + }); + + it('tests getNotDeletedCandidates called with 0 candidates', () => { + jest.spyOn(logger, 'logDebug'); + const candidates = getNotDeletedCandidates([]); + + // check if the log printed collectly with + expect(logger.logDebug).toHaveBeenCalledWith( + 'getNotDeletedCandidates called with 0 candidates', + ); + expect(candidates).toStrictEqual([]); + }); + + it('tests getNotDeletedCandidates called with multiple candidates', () => { + const activeTrip = userTrip; + const deletedTrip = { + data: { + end_ts: 1437604764, + start_ts: 1437601247, + label: 'FOO', + status: 'DELETED', + match_id: 'FOO', + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695921991, + platform: 'ios', + read_ts: 0, + key: 'manual/mode_confirm', + }, + key: 'manual/place', + } as UserInputEntry; + const candidates = [activeTrip, deletedTrip]; + const validCandidates = getNotDeletedCandidates(candidates); + + // check if the result has only 'ACTIVE' data + expect(validCandidates).toHaveLength(1); + expect(validCandidates[0]).toMatchObject(userTrip); + }); + + it('tests getUserInputForTrip with valid userInputList', () => { + const userInputWriteFirst = { + data: { + end_ts: 1437607732, + label: 'bus', + start_ts: 1437606026, + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695830232, + platform: 'ios', + read_ts: 0, + key: 'manual/mode_confirm', + type: 'message', + }, + } as unknown as UserInputEntry; + const userInputWriteSecond = { + data: { + end_ts: 1437598393, + label: 'e-bike', + start_ts: 1437596745, + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695838268, + platform: 'ios', + read_ts: 0, + key: 'manual/mode_confirm', + type: 'message', + }, + } as unknown as UserInputEntry; + const userInputWriteThird = { + data: { + end_ts: 1437604764, + label: 'e-bike', + start_ts: 1437601247, + }, + metadata: { + time_zone: 'America/Los_Angeles', + plugin: 'none', + write_ts: 1695921991, + platform: 'ios', + read_ts: 0, + key: 'manual/mode_confirm', + type: 'message', + }, + } as unknown as UserInputEntry; + + // make the linst unsorted and then check if userInputWriteThird(latest one) is return output + const userInputList = [userInputWriteSecond, userInputWriteThird, userInputWriteFirst]; + const mostRecentEntry = getUserInputForTimelineEntry(trip, nextTrip, userInputList); + expect(mostRecentEntry).toMatchObject(userInputWriteThird); + }); + + it('tests getUserInputForTrip with invalid userInputList', () => { + const userInputList = undefined as unknown as UserInputEntry[]; + const mostRecentEntry = getUserInputForTimelineEntry(trip, nextTrip, userInputList); + expect(mostRecentEntry).toBe(undefined); + }); + + it('tests getAdditionsForTimelineEntry with valid additionsList', () => { + const additionsList = new Array(5).fill(userTrip); + trip['key'] = 'analysis/confirmed_place'; + trip['origin_key'] = 'analysis/confirmed_place'; + + // check if the result keep the all valid userTrip items + const matchingAdditions = getAdditionsForTimelineEntry(trip, nextTrip, additionsList); + expect(matchingAdditions).toHaveLength(5); + }); + + it('tests getAdditionsForTimelineEntry with invalid additionsList', () => { + const additionsList = undefined as unknown as EnketoUserInputEntry[]; + const matchingAdditions = getAdditionsForTimelineEntry(trip, nextTrip, additionsList); + expect(matchingAdditions).toMatchObject([]); + }); + + it('tests getUniqueEntries with valid combinedList', () => { + const combinedList = new Array(5).fill(userTrip); + + // check if the result keeps only unique userTrip items + const uniqueEntires = getUniqueEntries(combinedList); + expect(uniqueEntires).toHaveLength(1); + }); + + it('tests getUniqueEntries with empty combinedList', () => { + const uniqueEntires = getUniqueEntries([]); + expect(uniqueEntires).toMatchObject([]); + }); +}); + +describe('mapInputsToTimelineEntries on a MULTILABEL configuration', () => { + const fakeConfigMultilabel = { + intro: {}, + survey_info: { + 'trip-labels': 'MULTILABEL', + }, + } as AppConfig; + + const timelineEntriesMultilabel = [ + { + _id: { $oid: 'trip1' }, + origin_key: 'analysis/confirmed_trip', + start_ts: 1000, + end_ts: 3000, + user_input: { + mode_confirm: 'walk', + }, + }, + { + _id: { $oid: 'placeA' }, + origin_key: 'analysis/confirmed_place', + enter_ts: 3000, + exit_ts: 5000, + // no user input + additions: [{ data: 'foo', metadata: 'bar' }], + }, + { + _id: { $oid: 'trip2' }, + origin_key: 'analysis/confirmed_trip', + start_ts: 5000, + end_ts: 7000, + // no user input + }, + ] as any as TimelineEntry[]; + it('creates a map that has the processed labels and notes', () => { + const [labelMap, notesMap] = mapInputsToTimelineEntries( + timelineEntriesMultilabel, + fakeConfigMultilabel, + ); + expect(labelMap).toMatchObject({ + trip1: { + MODE: { data: { label: 'walk' } }, + }, + }); + }); + it('creates a map that combines processed and unprocessed labels and notes', async () => { + // insert some unprocessed data + await window['cordova'].plugins.BEMUserCache.putMessage('manual/purpose_confirm', { + label: 'recreation', + start_ts: 1000, + end_ts: 3000, + }); + await window['cordova'].plugins.BEMUserCache.putMessage('manual/mode_confirm', { + label: 'bike', + start_ts: 5000, + end_ts: 7000, + }); + await updateLocalUnprocessedInputs({ start_ts: 1000, end_ts: 5000 }, fakeConfigMultilabel); + + // check that both processed and unprocessed data are returned + const [labelMap, notesMap] = mapInputsToTimelineEntries( + timelineEntriesMultilabel, + fakeConfigMultilabel, + ); + + expect(labelMap).toMatchObject({ + trip1: { + MODE: { data: { label: 'walk' } }, + PURPOSE: { data: { label: 'recreation' } }, + }, + trip2: { + MODE: { data: { label: 'bike' } }, + }, + }); + }); +}); + +describe('mapInputsToTimelineEntries on an ENKETO configuration', () => { + const fakeConfigEnketo = { + intro: {}, + survey_info: { + 'trip-labels': 'ENKETO', + buttons: { + 'trip-notes': { surveyName: 'TimeSurvey' }, + }, + surveys: { TripConfirmSurvey: { compatibleWith: 1 } }, + }, + } as any as AppConfig; + const timelineEntriesEnketo = [ + { + _id: { $oid: 'trip1' }, + origin_key: 'analysis/confirmed_trip', + start_ts: 1000, + end_ts: 3000, + user_input: { + trip_user_input: { + data: { + name: 'TripConfirmSurvey', + version: 1, + xmlResponse: '', + start_ts: 1000, + end_ts: 3000, + }, + metadata: 'foo', + }, + }, + additions: [ + { + data: { + name: 'TimeSurvey', + xmlResponse: '', + start_ts: 1000, + end_ts: 2000, + }, + metadata: 'foo', + }, + ], + }, + { + _id: { $oid: 'trip2' }, + origin_key: 'analysis/confirmed_trip', + start_ts: 5000, + end_ts: 7000, + // no user input + additions: [ + { + data: { + name: 'TimeSurvey', + xmlResponse: '', + match_id: 'foo', + start_ts: 5000, + end_ts: 7000, + }, + metadata: 'foo', + }, + ], + }, + ] as any as TimelineEntry[]; + it('creates a map that has the processed responses and notes', () => { + const [labelMap, notesMap] = mapInputsToTimelineEntries( + timelineEntriesEnketo, + fakeConfigEnketo, + ); + expect(labelMap).toMatchObject({ + trip1: { + SURVEY: { + data: { xmlResponse: '' }, + }, + }, + }); + expect(notesMap['trip1'].length).toBe(1); + expect(notesMap['trip1'][0]).toMatchObject({ + data: { xmlResponse: '' }, + }); + }); + it('creates a map that combines processed and unprocessed responses and notes', async () => { + // insert some unprocessed data + await window['cordova'].plugins.BEMUserCache.putMessage('manual/trip_user_input', { + name: 'TripConfirmSurvey', + version: 1, + xmlResponse: '', + start_ts: 5000, + end_ts: 7000, + }); + await window['cordova'].plugins.BEMUserCache.putMessage('manual/trip_addition_input', { + name: 'TimeSurvey', + xmlResponse: '', + match_id: 'bar', + start_ts: 6000, + end_ts: 7000, + }); + await updateLocalUnprocessedInputs({ start_ts: 1000, end_ts: 5000 }, fakeConfigEnketo); + + // check that both processed and unprocessed data are returned + const [labelMap, notesMap] = mapInputsToTimelineEntries( + timelineEntriesEnketo, + fakeConfigEnketo, + ); + + expect(labelMap).toMatchObject({ + trip1: { + SURVEY: { + data: { xmlResponse: '' }, + }, + }, + trip2: { + SURVEY: { + data: { xmlResponse: '' }, + }, + }, + }); + + expect(notesMap['trip1'].length).toBe(1); + expect(notesMap['trip1'][0]).toMatchObject({ + data: { xmlResponse: '' }, + }); + + expect(notesMap['trip2'].length).toBe(2); + expect(notesMap['trip2'][0]).toMatchObject({ + data: { xmlResponse: '' }, + }); + expect(notesMap['trip2'][1]).toMatchObject({ + data: { xmlResponse: '' }, + }); + }); +}); diff --git a/www/__tests__/metHelper.test.ts b/www/__tests__/metHelper.test.ts new file mode 100644 index 000000000..8457f3dc5 --- /dev/null +++ b/www/__tests__/metHelper.test.ts @@ -0,0 +1,44 @@ +import { getMet } from '../js/metrics/metHelper'; +import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import fakeLabels from '../__mocks__/fakeLabels.json'; +import { getConfig } from '../js/config/dynamicConfig'; +import { initCustomDatasetHelper } from '../js/metrics/customMetricsHelper'; +import fakeConfig from '../__mocks__/fakeConfig.json'; + +mockBEMUserCache(fakeConfig); +mockLogger(); + +global.fetch = (url: string) => + new Promise((rs, rj) => { + setTimeout(() => + rs({ + text: () => + new Promise((rs, rj) => { + let myJSON = JSON.stringify(fakeLabels); + setTimeout(() => rs(myJSON), 100); + }), + }), + ); + }) as any; + +it('gets met for mode and speed', () => { + expect(getMet('WALKING', 1.47523, 0)).toBe(4.3); //1.47523 mps = 3.299 mph -> 4.3 METs + expect(getMet('BICYCLING', 4.5, 0)).toBe(6.8); //4.5 mps = 10.07 mph = 6.8 METs + expect(getMet('UNICYCLE', 100, 0)).toBe(0); //unkown mode, 0 METs + expect(getMet('CAR', 25, 1)).toBe(0); //0 METs in CAR + expect(getMet('ON_FOOT', 1.47523, 0)).toBe(4.3); //same as walking! + expect(getMet('WALKING', -2, 0)).toBe(0); //negative speed -> 0 +}); + +it('gets custom met for mode and speed', async () => { + const appConfig = await getConfig(); + await initCustomDatasetHelper(appConfig); + expect(getMet('walk', 1.47523, 0)).toBe(4.3); //1.47523 mps = 3.299 mph -> 4.3 METs + expect(getMet('bike', 4.5, 0)).toBe(6.8); //4.5 mps = 10.07 mph = 6.8 METs + expect(getMet('unicycle', 100, 0)).toBe(0); //unkown mode, 0 METs + expect(getMet('drove_alone', 25, 1)).toBe(0); //0 METs IN_VEHICLE + expect(getMet('e-bike', 6, 1)).toBe(4.9); //e-bike is 4.9 for all speeds + expect(getMet('e-bike', 12, 1)).toBe(4.9); //e-bike is 4.9 for all speeds + expect(getMet('walk', -2, 1)).toBe(0); //negative speed -> 0 +}); diff --git a/www/__tests__/notifScheduler.test.ts b/www/__tests__/notifScheduler.test.ts new file mode 100644 index 000000000..ac6408204 --- /dev/null +++ b/www/__tests__/notifScheduler.test.ts @@ -0,0 +1,476 @@ +import { mockReminders } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import i18next from 'i18next'; +import { logDebug } from '../js/plugin/logger'; +import { DateTime } from 'luxon'; +import { getUser, updateUser } from '../js/services/commHelper'; +import { addStatReading } from '../js/plugin/clientStats'; +import { + getScheduledNotifs, + updateScheduledNotifs, + getReminderPrefs, + setReminderPrefs, +} from '../js/splash/notifScheduler'; + +const exampleReminderSchemes = { + weekly: { + title: { + en: 'Please take a moment to label your trips', + es: 'Por favor, tĆ³mese un momento para etiquetar sus viajes', + }, + text: { + en: 'Click to open the app and view unlabeled trips', + es: 'Haga clic para abrir la aplicaciĆ³n y ver los viajes sin etiquetar', + }, + schedule: [ + { start: 0, end: 1, intervalInDays: 1 }, + { start: 3, end: 5, intervalInDays: 2 }, + ], + defaultTime: '21:00', + }, + 'week-quarterly': { + title: { + en: 'Please take a moment to label your trips', + es: 'Por favor, tĆ³mese un momento para etiquetar sus viajes', + }, + text: { + en: 'Click to open the app and view unlabeled trips', + es: 'Haga clic para abrir la aplicaciĆ³n y ver los viajes sin etiquetar', + }, + schedule: [ + { start: 0, end: 1, intervalInDays: 1 }, + { start: 3, end: 5, intervalInDays: 2 }, + ], + defaultTime: '22:00', + }, + passive: { + title: { + en: 'Please take a moment to label your trips', + es: 'Por favor, tĆ³mese un momento para etiquetar sus viajes', + }, + text: { + en: 'Click to open the app and view unlabeled trips', + es: 'Haga clic para abrir la aplicaciĆ³n y ver los viajes sin etiquetar', + }, + schedule: [ + { start: 0, end: 1, intervalInDays: 1 }, + { start: 3, end: 5, intervalInDays: 2 }, + ], + defaultTime: '23:00', + }, +}; + +mockLogger(); +mockReminders(); + +jest.mock('i18next', () => ({ + resolvedLanguage: 'en', +})); + +jest.mock('../js/services/commHelper', () => ({ + getUser: jest.fn(), + updateUser: jest.fn(), +})); +const mockGetUser = getUser as jest.Mock; +const mockUpdateUser = updateUser as jest.Mock; + +jest.mock('../js/plugin/clientStats', () => ({ + ...jest.requireActual('../js/plugin/clientStats'), + addStatReading: jest.fn(), +})); + +jest.mock('../js/plugin/logger', () => ({ + ...jest.requireActual('../js/plugin/logger'), + logDebug: jest.fn(), +})); + +jest.mock('../js/splash/notifScheduler', () => ({ + ...jest.requireActual('../js/splash/notifScheduler'), + // for getScheduledNotifs + getNotifs: jest.fn(), + // for updateScheduledNotifs + calcNotifTimes: jest.fn(), + removeEmptyObjects: jest.fn(), + areAlreadyScheduled: jest.fn(), + scheduleNotifs: jest.fn(), +})); + +jest.mock('../js/plugin/clientStats'); + +describe('getScheduledNotifs', () => { + it('should resolve with notifications while not actively scheduling', async () => { + // getScheduledNotifs arguments + const isScheduling = false; + const scheduledPromise = Promise.resolve(); + // create the mock notifs from cordova plugin + const mockNotifs = [{ trigger: { at: DateTime.now().toJSDate() } }]; + // create the expected result + const expectedResult = [ + { + key: DateTime.fromJSDate(mockNotifs[0].trigger.at).toFormat('DDD'), + val: DateTime.fromJSDate(mockNotifs[0].trigger.at).toFormat('t'), + }, + ]; + + // mock the cordova plugin + jest + .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') + .mockImplementation((callback) => callback(mockNotifs)); + // call the function + const scheduledNotifs = await getScheduledNotifs(isScheduling, Promise.resolve()); + + expect(scheduledNotifs).toEqual(expectedResult); + }); + + it('should resolve with notifications if actively scheduling', async () => { + // getScheduledNotifs arguments + const isScheduling = true; + const scheduledPromise = Promise.resolve(); + // create the mock notifs from cordova plugin + const mockNotifs = [{ trigger: { at: DateTime.now().toJSDate() } }]; + // create the expected result + const expectedResult = [ + { + key: DateTime.fromJSDate(mockNotifs[0].trigger.at).toFormat('DDD'), + val: DateTime.fromJSDate(mockNotifs[0].trigger.at).toFormat('t'), + }, + ]; + + // mock the cordova plugin + jest + .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') + .mockImplementation((callback) => callback(mockNotifs)); + // call the funciton + const scheduledNotifs = await getScheduledNotifs(isScheduling, scheduledPromise); + + expect(scheduledNotifs).toEqual(expectedResult); + }); + + it('should handle case where no notifications are present', async () => { + // getScheduledNotifs arguments + const isScheduling = false; + const scheduledPromise = Promise.resolve(); + // create the mock notifs from cordova plugin + const mockNotifs = []; + // create the expected result + const expectedResult = []; + + // mock the cordova plugin + jest + .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') + .mockImplementation((callback) => callback(mockNotifs)); + // call the funciton + const scheduledNotifs = await getScheduledNotifs(isScheduling, Promise.resolve()); + + expect(scheduledNotifs).toEqual(expectedResult); + }); + + it('should handle the case where greater than 5 notifications are present', async () => { + // getScheduledNotifs arguments + const isScheduling = false; + const scheduledPromise = Promise.resolve(); + // create the mock notifs from cordova plugin (greater than 5 notifications) + const mockNotifs = [ + { trigger: { at: DateTime.now().toJSDate() } }, + { trigger: { at: DateTime.now().plus({ weeks: 1 }).toJSDate() } }, + { trigger: { at: DateTime.now().plus({ weeks: 2 }).toJSDate() } }, + { trigger: { at: DateTime.now().plus({ weeks: 3 }).toJSDate() } }, + { trigger: { at: DateTime.now().plus({ weeks: 4 }).toJSDate() } }, + { trigger: { at: DateTime.now().plus({ weeks: 5 }).toJSDate() } }, + { trigger: { at: DateTime.now().plus({ weeks: 6 }).toJSDate() } }, + { trigger: { at: DateTime.now().plus({ weeks: 7 }).toJSDate() } }, + ]; + // create the expected result (only the first 5 notifications) + const expectedResult = [ + { + key: DateTime.fromJSDate(mockNotifs[0].trigger.at as Date).toFormat('DDD'), + val: DateTime.fromJSDate(mockNotifs[0].trigger.at as Date).toFormat('t'), + }, + { + key: DateTime.fromJSDate(mockNotifs[1].trigger.at as Date).toFormat('DDD'), + val: DateTime.fromJSDate(mockNotifs[1].trigger.at as Date).toFormat('t'), + }, + { + key: DateTime.fromJSDate(mockNotifs[2].trigger.at as Date).toFormat('DDD'), + val: DateTime.fromJSDate(mockNotifs[2].trigger.at as Date).toFormat('t'), + }, + { + key: DateTime.fromJSDate(mockNotifs[3].trigger.at as Date).toFormat('DDD'), + val: DateTime.fromJSDate(mockNotifs[3].trigger.at as Date).toFormat('t'), + }, + { + key: DateTime.fromJSDate(mockNotifs[4].trigger.at as Date).toFormat('DDD'), + val: DateTime.fromJSDate(mockNotifs[4].trigger.at as Date).toFormat('t'), + }, + ]; + + // mock the cordova plugin + jest + .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') + .mockImplementation((callback) => callback(mockNotifs)); + // call the funciton + const scheduledNotifs = await getScheduledNotifs(isScheduling, Promise.resolve()); + + expect(scheduledNotifs).toEqual(expectedResult); + }); +}); + +describe('updateScheduledNotifs', () => { + afterEach(() => { + jest.restoreAllMocks(); // Restore mocked functions after each test + }); + + beforeEach(() => { + // mock the getUser function + mockGetUser.mockImplementation(() => + Promise.resolve({ + // These values are **important**... + // reminder_assignment: must match a key from the reminder scheme above, + // reminder_join_date: must match the first day of the mocked notifs below in the tests, + // reminder_time_of_day: must match the defaultTime from the chosen reminder_assignment in the reminder scheme above + reminder_assignment: 'weekly', + reminder_join_date: '2023-11-14', + reminder_time_of_day: '21:00', + }), + ); + }); + + it('should resolve after scheduling notifications', async () => { + // updateScheduleNotifs arguments + const reminderSchemes: any = exampleReminderSchemes; + let isScheduling: boolean = false; + const setIsScheduling: Function = jest.fn((val: boolean) => (isScheduling = val)); + const scheduledPromise: Promise = Promise.resolve(); + // create an empty array of mock notifs from cordova plugin + let mockNotifs: any[] = []; + // create the expected result + const expectedResultcheduleNotifs = [ + { key: 'November 19, 2023', val: '9:00ā€ÆPM' }, + { key: 'November 17, 2023', val: '9:00ā€ÆPM' }, + { key: 'November 15, 2023', val: '9:00ā€ÆPM' }, + { key: 'November 14, 2023', val: '9:00ā€ÆPM' }, + ]; + + // mock the cordova plugin + jest + .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') + .mockImplementation((callback) => callback(mockNotifs)); + jest + .spyOn(window['cordova'].plugins.notification.local, 'cancelAll') + .mockImplementation((callback) => { + mockNotifs = []; + callback(); + }); + jest + .spyOn(window['cordova'].plugins.notification.local, 'schedule') + .mockImplementation((arg, callback) => { + arg.forEach((notif) => { + mockNotifs.push(notif); + }); + console.log('called mockNotifs.concat(arg)', mockNotifs); + callback(arg); + }); + // call the function + await updateScheduledNotifs(reminderSchemes, isScheduling, setIsScheduling, scheduledPromise); + const scheduledNotifs = await getScheduledNotifs(isScheduling, scheduledPromise); + + expect(scheduledNotifs).toHaveLength(4); + expect(scheduledNotifs[0].key).toEqual(expectedResultcheduleNotifs[0].key); + expect(scheduledNotifs[1].key).toEqual(expectedResultcheduleNotifs[1].key); + expect(scheduledNotifs[2].key).toEqual(expectedResultcheduleNotifs[2].key); + expect(scheduledNotifs[3].key).toEqual(expectedResultcheduleNotifs[3].key); + }); + + it('should resolve without scheduling if notifications are already scheduled', async () => { + // updateScheduleNotifs arguments + const reminderSchemes: any = exampleReminderSchemes; + let isScheduling: boolean = false; + const setIsScheduling: Function = jest.fn((val: boolean) => (isScheduling = val)); + const scheduledPromise: Promise = Promise.resolve(); + // create the mock notifs from cordova plugin (must match the notifs that will generate from the reminder scheme above... + // in this case: exampleReminderSchemes.weekly, because getUser is mocked to return reminder_assignment: 'weekly') + const mockNotifs = [ + { trigger: { at: DateTime.fromFormat('2023-11-14 21:00', 'yyyy-MM-dd HH:mm').toJSDate() } }, + { trigger: { at: DateTime.fromFormat('2023-11-15 21:00', 'yyyy-MM-dd HH:mm').toJSDate() } }, + { trigger: { at: DateTime.fromFormat('2023-11-17 21:00', 'yyyy-MM-dd HH:mm').toJSDate() } }, + { trigger: { at: DateTime.fromFormat('2023-11-19 21:00', 'yyyy-MM-dd HH:mm').toJSDate() } }, + ]; + + // mock the cordova plugin + jest + .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') + .mockImplementation((callback) => callback(mockNotifs)); + // call the function + await updateScheduledNotifs(reminderSchemes, isScheduling, setIsScheduling, scheduledPromise); + + expect(logDebug).toHaveBeenCalledWith('Already scheduled, not scheduling again'); + }); + + it('should wait for the previous scheduling to finish if isScheduling is true', async () => { + // updateScheduleNotifs arguments + const reminderSchemes: any = exampleReminderSchemes; + let isScheduling: boolean = true; + const setIsScheduling: Function = jest.fn((val: boolean) => (isScheduling = val)); + const scheduledPromise: Promise = Promise.resolve(); + // create an empty array of mock notifs from cordova plugin + const mockNotifs = []; + + // mock the cordova plugin + jest + .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') + .mockImplementation((callback) => callback(mockNotifs)); + // call the function + await updateScheduledNotifs(reminderSchemes, isScheduling, setIsScheduling, scheduledPromise); + + expect(logDebug).toHaveBeenCalledWith( + 'ERROR: Already scheduling notifications, not scheduling again', + ); + }); + + it('should log an error message if the reminder scheme is missing', async () => { + // updateScheduleNotifs arguments + let reminderSchemes: any = exampleReminderSchemes; + delete reminderSchemes.weekly; // delete the weekly reminder scheme, to create a missing reminder scheme error + let isScheduling: boolean = false; + const setIsScheduling: Function = jest.fn((val: boolean) => (isScheduling = val)); + const scheduledPromise: Promise = Promise.resolve(); + // call the function + await updateScheduledNotifs(reminderSchemes, isScheduling, setIsScheduling, scheduledPromise); + + expect(logDebug).toHaveBeenCalledWith('Error: Reminder scheme not found'); + }); +}); + +describe('getReminderPrefs', () => { + afterEach(() => { + jest.restoreAllMocks(); // Restore mocked functions after each test + }); + + it('should resolve with newly initialilzed reminder prefs when user does not exist', async () => { + // getReminderPrefs arguments + const reminderSchemes: any = exampleReminderSchemes; + let isScheduling: boolean = true; + const setIsScheduling: Function = jest.fn((val: boolean) => (isScheduling = val)); + const scheduledPromise: Promise = Promise.resolve(); + // create the expected result + const expectedResult = { + reminder_assignment: 'weekly', + reminder_join_date: '2023-11-14', + reminder_time_of_day: '21:00', + }; + + // mock the getUser function to return a user that does not exist: + // first, as undefined to get the not-yet-created user behavior, + // then, as a user with reminder prefs to prevent infinite looping (since updateUser does not update the user) + mockGetUser + .mockImplementation(() => + Promise.resolve({ + reminder_assignment: 'weekly', + reminder_join_date: '2023-11-14', + reminder_time_of_day: '21:00', + }), + ) + .mockImplementationOnce(() => + Promise.resolve({ + reminder_assignment: undefined, + reminder_join_date: undefined, + reminder_time_of_day: undefined, + }), + ); + // mock addStatReading for the setReminderPrefs portion of getReminderPrefs + // typescript causes us to need to use "... as jest.Mock" to mock funcitons that are imported from other files + (addStatReading as jest.Mock).mockImplementation(() => Promise.resolve()); + + // call the function + const { reminder_assignment, reminder_join_date, reminder_time_of_day } = + await getReminderPrefs(reminderSchemes, isScheduling, setIsScheduling, scheduledPromise); + + expect(logDebug).toHaveBeenCalledWith('User just joined, Initializing reminder prefs'); + expect(logDebug).toHaveBeenCalledWith('Added reminder prefs to client stats'); + }); + + it('should resolve with reminder prefs when user exists', async () => { + // getReminderPrefs arguments + const reminderSchemes: any = exampleReminderSchemes; + let isScheduling: boolean = true; + const setIsScheduling: Function = jest.fn((val: boolean) => (isScheduling = val)); + const scheduledPromise: Promise = Promise.resolve(); + // create the expected result + const expectedResult = { + reminder_assignment: 'weekly', + reminder_join_date: '2023-11-14', + reminder_time_of_day: '21:00', + }; + + // mock the getUser function + mockGetUser.mockImplementation(() => + Promise.resolve({ + // These values are **important**... + // reminder_assignment: must match a key from the reminder scheme above, + // reminder_join_date: must match the first day of the mocked notifs below in the tests, + // reminder_time_of_day: must match the defaultTime from the chosen reminder_assignment in the reminder scheme above + reminder_assignment: 'weekly', + reminder_join_date: '2023-11-14', + reminder_time_of_day: '21:00', + }), + ); + + // call the function + const { reminder_assignment, reminder_join_date, reminder_time_of_day } = + await getReminderPrefs(reminderSchemes, isScheduling, setIsScheduling, scheduledPromise); + + expect(reminder_assignment).toEqual(expectedResult.reminder_assignment); + expect(reminder_join_date).toEqual(expectedResult.reminder_join_date); + expect(reminder_time_of_day).toEqual(expectedResult.reminder_time_of_day); + }); +}); + +describe('setReminderPrefs', () => { + afterEach(() => { + jest.restoreAllMocks(); // Restore mocked functions after each test + }); + + beforeEach(() => { + // mock the getUser function + mockGetUser.mockImplementation(() => + Promise.resolve({ + // These values are **important**... + // reminder_assignment: must match a key from the reminder scheme above, + // reminder_join_date: must match the first day of the mocked notifs below in the tests, + // reminder_time_of_day: must match the defaultTime from the chosen reminder_assignment in the reminder scheme above + reminder_assignment: 'weekly', + reminder_join_date: '2023-11-14', + reminder_time_of_day: '21:00', + }), + ); + }); + + it('should resolve with promise that calls updateScheduledNotifs', async () => { + // setReminderPrefs arguments + const newPrefs: any = { + reminder_time_of_day: '21:00', + }; + const reminderSchemes: any = exampleReminderSchemes; + let isScheduling: boolean = true; + const setIsScheduling: Function = jest.fn((val: boolean) => (isScheduling = val)); + const scheduledPromise: Promise = Promise.resolve(); + + // mock the updateUser function + mockUpdateUser.mockImplementation(() => Promise.resolve()); + + // call the function + setReminderPrefs( + newPrefs, + reminderSchemes, + isScheduling, + setIsScheduling, + scheduledPromise, + ).then(() => { + // in the implementation in ProfileSettings.jsx, + // refresNotificationSettings(); + // would be called next + }); + + expect(logDebug).toBeCalledWith('Added reminder prefs to client stats'); + }); +}); diff --git a/www/__tests__/pushNotifySettings.test.ts b/www/__tests__/pushNotifySettings.test.ts new file mode 100644 index 000000000..d452aa819 --- /dev/null +++ b/www/__tests__/pushNotifySettings.test.ts @@ -0,0 +1,119 @@ +import { DateTime } from 'luxon'; +import { EVENTS, publish } from '../js/customEventHandler'; +import { INTRO_DONE_KEY, readIntroDone } from '../js/onboarding/onboardingHelper'; +import { storageSet } from '../js/plugin/storage'; +import { initPushNotify } from '../js/splash/pushNotifySettings'; +import { mockCordova, mockBEMUserCache, mockBEMDataCollection } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import { + clearNotifMock, + getOnList, + mockPushNotification, + getCalled, +} from '../__mocks__/pushNotificationMocks'; + +mockCordova(); +mockLogger(); +mockPushNotification(); +mockBEMUserCache(); +mockBEMDataCollection(); + +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; + +afterEach(() => { + clearNotifMock(); +}); + +it('intro done does nothing if not registered', () => { + expect(getOnList()).toStrictEqual({}); + publish(EVENTS.INTRO_DONE_EVENT, 'test data'); + expect(getOnList()).toStrictEqual({}); +}); + +it('intro done initializes the push notifications', () => { + expect(getOnList()).toStrictEqual({}); + + initPushNotify(); + publish(EVENTS.INTRO_DONE_EVENT, 'test data'); + expect(getOnList()).toStrictEqual( + expect.objectContaining({ + notification: expect.any(Function), + error: expect.any(Function), + registration: expect.any(Function), + }), + ); +}); + +it('cloud event does nothing if not registered', () => { + expect(window['cordova'].platformId).toEqual('ios'); + publish(EVENTS.CLOUD_NOTIFICATION_EVENT, { + additionalData: { 'content-available': 1, payload: { notId: 3 } }, + }); + expect(getCalled()).toBeNull(); +}); + +it('cloud event handles notification if registered', async () => { + expect(window['cordova'].platformId).toEqual('ios'); + initPushNotify(); + publish(EVENTS.INTRO_DONE_EVENT, 'intro done'); + publish(EVENTS.CLOUD_NOTIFICATION_EVENT, { + additionalData: { 'content-available': 1, payload: { notId: 3 } }, + }); + await new Promise((r) => setTimeout(r, 1000)); + expect(getCalled()).toEqual(3); +}); + +it('consent event does nothing if not registered', () => { + expect(getOnList()).toStrictEqual({}); + publish(EVENTS.CONSENTED_EVENT, 'test data'); + expect(getOnList()).toStrictEqual({}); +}); + +it('consent event registers if intro done', async () => { + //make sure the mock is clear + expect(getOnList()).toStrictEqual({}); + + //initialize the pushNotify, to subscribe to events + initPushNotify(); + + //mark the intro as done + const currDateTime = DateTime.now().toISO(); + let marked = await storageSet(INTRO_DONE_KEY, currDateTime); + let introDone = await readIntroDone(); + expect(introDone).toBeTruthy(); + + //publish consent event and check results + publish(EVENTS.CONSENTED_EVENT, 'test data'); + //have to wait a beat since event response is async + await new Promise((r) => setTimeout(r, 1000)); + expect(getOnList()).toStrictEqual( + expect.objectContaining({ + notification: expect.any(Function), + error: expect.any(Function), + registration: expect.any(Function), + }), + ); +}); + +it('consent event does not register if intro not done', () => { + expect(getOnList()).toStrictEqual({}); + initPushNotify(); + publish(EVENTS.CONSENTED_EVENT, 'test data'); + expect(getOnList()).toStrictEqual({}); //nothing, intro not done +}); diff --git a/www/__tests__/remoteNotifyHandler.test.ts b/www/__tests__/remoteNotifyHandler.test.ts new file mode 100644 index 000000000..320877c6b --- /dev/null +++ b/www/__tests__/remoteNotifyHandler.test.ts @@ -0,0 +1,76 @@ +import { EVENTS, publish } from '../js/customEventHandler'; +import { initRemoteNotifyHandler } from '../js/splash/remoteNotifyHandler'; +import { + clearURL, + getURL, + mockBEMUserCache, + mockDevice, + mockGetAppVersion, + mockInAppBrowser, +} from '../__mocks__/cordovaMocks'; +import { clearAlerts, getAlerts, mockAlert, mockLogger } from '../__mocks__/globalMocks'; + +mockLogger(); +mockDevice(); +mockBEMUserCache(); +mockGetAppVersion(); +mockInAppBrowser(); +mockAlert(); + +const db = window['cordova']?.plugins?.BEMUserCache; + +beforeEach(() => { + clearURL(); + clearAlerts(); +}); + +it('does not adds a statEvent if not subscribed', async () => { + publish(EVENTS.CLOUD_NOTIFICATION_EVENT, 'test data'); + const storedMessages = await db.getAllMessages('stats/client_nav_event', false); + expect(storedMessages).toEqual([]); +}); + +it('adds a statEvent if subscribed', async () => { + initRemoteNotifyHandler(); + await new Promise((r) => setTimeout(r, 500)); //wait for subscription + publish(EVENTS.CLOUD_NOTIFICATION_EVENT, 'test data'); + await new Promise((r) => setTimeout(r, 500)); //wait for event handling + const storedMessages = await db.getAllMessages('stats/client_nav_event', false); + expect(storedMessages).toContainEqual({ + name: 'notification_open', + ts: expect.any(Number), + reading: null, + client_app_version: '1.2.3', + client_os_version: '14.0.0', + }); +}); + +it('handles the url if subscribed', () => { + initRemoteNotifyHandler(); + publish(EVENTS.CLOUD_NOTIFICATION_EVENT, { + additionalData: { + payload: { alert_type: 'website', spec: { url: 'https://this_is_a_test.com' } }, + }, + }); + expect(getURL()).toBe('https://this_is_a_test.com'); +}); + +it('handles the popup if subscribed', () => { + initRemoteNotifyHandler(); + publish(EVENTS.CLOUD_NOTIFICATION_EVENT, { + additionalData: { + payload: { + alert_type: 'popup', + spec: { title: 'Hello', text: 'World' }, + }, + }, + }); + expect(getAlerts()).toEqual(expect.arrayContaining(['Hello World'])); +}); + +it('does nothing if subscribed and no data', () => { + initRemoteNotifyHandler(); + publish(EVENTS.CLOUD_NOTIFICATION_EVENT, {}); + expect(getURL()).toEqual(''); + expect(getAlerts()).toEqual([]); +}); diff --git a/www/__tests__/startprefs.test.ts b/www/__tests__/startprefs.test.ts new file mode 100644 index 000000000..75ed707dc --- /dev/null +++ b/www/__tests__/startprefs.test.ts @@ -0,0 +1,41 @@ +import { + markConsented, + isConsented, + readConsentState, + getConsentDocument, +} from '../js/splash/startprefs'; + +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; + +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', + }); +}); diff --git a/www/__tests__/storage.test.ts b/www/__tests__/storage.test.ts new file mode 100644 index 000000000..ca6d71dec --- /dev/null +++ b/www/__tests__/storage.test.ts @@ -0,0 +1,74 @@ +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 :) +mockLogger(); +mockBEMUserCache(); + +it('stores a value and retrieves it back', async () => { + await storageSet('test1', 'test value 1'); + const retVal = await storageGet('test1'); + expect(retVal).toEqual('test value 1'); +}); + +it('stores a value, removes it, and checks that it is gone', async () => { + await storageSet('test2', 'test value 2'); + await storageRemove('test2'); + const retVal = await storageGet('test2'); + expect(retVal).toBeUndefined(); +}); + +it('can store objects too', async () => { + const obj = { a: 1, b: 2 }; + await storageSet('test6', obj); + const retVal = await storageGet('test6'); + expect(retVal).toEqual(obj); +}); + +it('can also store complex nested objects with arrays', async () => { + const obj = { a: 1, b: { c: [1, 2, 3] } }; + await storageSet('test7', obj); + const retVal = await storageGet('test7'); + expect(retVal).toEqual(obj); +}); + +it('preserves values if local gets cleared', async () => { + await storageSet('test3', 'test value 3'); + await storageClear({ local: true }); + const retVal = await storageGet('test3'); + expect(retVal).toEqual('test value 3'); +}); + +it('preserves values if native gets cleared', async () => { + await storageSet('test4', 'test value 4'); + await storageClear({ native: true }); + const retVal = await storageGet('test4'); + expect(retVal).toEqual('test value 4'); +}); + +it('does not preserve values if both local and native are cleared', async () => { + await storageSet('test5', 'test value 5'); + await storageClear({ local: true, native: true }); + const retVal = await storageGet('test5'); + expect(retVal).toBeUndefined(); +}); + +it('preserves values if local gets cleared, then retrieved, then native gets cleared', async () => { + await storageSet('test8', 'test value 8'); + await storageClear({ local: true }); + await storageGet('test8'); + await storageClear({ native: true }); + const retVal = await storageGet('test8'); + expect(retVal).toEqual('test value 8'); +}); + +it('preserves values if native gets cleared, then retrieved, then local gets cleared', async () => { + await storageSet('test9', 'test value 9'); + await storageClear({ native: true }); + await storageGet('test9'); + await storageClear({ local: true }); + const retVal = await storageGet('test9'); + expect(retVal).toEqual('test value 9'); +}); diff --git a/www/__tests__/storeDeviceSettings.test.ts b/www/__tests__/storeDeviceSettings.test.ts new file mode 100644 index 000000000..4bccbc0af --- /dev/null +++ b/www/__tests__/storeDeviceSettings.test.ts @@ -0,0 +1,120 @@ +import { readConsentState, markConsented } from '../js/splash/startprefs'; +import { storageClear } from '../js/plugin/storage'; +import { getUser } from '../js/services/commHelper'; +import { initStoreDeviceSettings, teardownDeviceSettings } from '../js/splash/storeDeviceSettings'; +import { + mockBEMDataCollection, + mockBEMServerCom, + mockBEMUserCache, + mockCordova, + mockDevice, + mockGetAppVersion, +} from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import { EVENTS, publish } from '../js/customEventHandler'; +import { markIntroDone } from '../js/onboarding/onboardingHelper'; + +mockBEMUserCache(); +mockDevice(); +mockCordova(); +mockLogger(); +mockGetAppVersion(); +mockBEMServerCom(); +mockBEMDataCollection(); + +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; + +beforeEach(async () => { + teardownDeviceSettings(); + await storageClear({ local: true, native: true }); + let user = await getUser(); + expect(user).toBeUndefined(); +}); + +it('stores device settings when intialized after consent', async () => { + await readConsentState(); + let marked = await markConsented(); + await new Promise((r) => setTimeout(r, 500)); + initStoreDeviceSettings(); + await new Promise((r) => setTimeout(r, 500)); + let user = await getUser(); + expect(user).toMatchObject({ + client_os_version: '14.0.0', + client_app_version: '1.2.3', + }); +}); + +it('does not stores device settings when intialized before consent', async () => { + initStoreDeviceSettings(); + await new Promise((r) => setTimeout(r, 500)); + let user = await getUser(); + expect(user).toBeUndefined(); +}); + +it('verifies my subscrition clearing', async () => { + initStoreDeviceSettings(); + await new Promise((r) => setTimeout(r, 500)); + teardownDeviceSettings(); + publish(EVENTS.INTRO_DONE_EVENT, 'test data'); + let user = await getUser(); + expect(user).toBeUndefined(); +}); + +it('does not store if not subscribed', async () => { + publish(EVENTS.INTRO_DONE_EVENT, 'test data'); + publish(EVENTS.CONSENTED_EVENT, 'test data'); + await new Promise((r) => setTimeout(r, 500)); //time to carry out event handling + let user = await getUser(); + expect(user).toBeUndefined(); +}); + +it('stores device settings after intro done', async () => { + initStoreDeviceSettings(); + await new Promise((r) => setTimeout(r, 500)); //time to check consent and subscribe + publish(EVENTS.INTRO_DONE_EVENT, 'test data'); + await new Promise((r) => setTimeout(r, 500)); //time to carry out event handling + let user = await getUser(); + expect(user).toMatchObject({ + client_os_version: '14.0.0', + client_app_version: '1.2.3', + }); +}); + +it('stores device settings after consent if intro done', async () => { + initStoreDeviceSettings(); + await new Promise((r) => setTimeout(r, 500)); //time to check consent and subscribe + markIntroDone(); + await new Promise((r) => setTimeout(r, 500)); + publish(EVENTS.CONSENTED_EVENT, 'test data'); + await new Promise((r) => setTimeout(r, 500)); //time to carry out event handling + let user = await getUser(); + expect(user).toMatchObject({ + client_os_version: '14.0.0', + client_app_version: '1.2.3', + }); +}); + +it('does not store device settings after consent if intro not done', async () => { + initStoreDeviceSettings(); + await new Promise((r) => setTimeout(r, 500)); //time to check consent and subscribe + publish(EVENTS.CONSENTED_EVENT, 'test data'); + await new Promise((r) => setTimeout(r, 500)); //time to carry out event handling + let user = await getUser(); + expect(user).toBeUndefined(); +}); diff --git a/www/__tests__/timelineHelper.test.ts b/www/__tests__/timelineHelper.test.ts new file mode 100644 index 000000000..aafe13926 --- /dev/null +++ b/www/__tests__/timelineHelper.test.ts @@ -0,0 +1,314 @@ +import { clearAlerts, mockAlert, mockLogger } from '../__mocks__/globalMocks'; +import { + useGeojsonForTrip, + readAllCompositeTrips, + readUnprocessedTrips, + compositeTrips2TimelineMap, + keysForLabelInputs, + updateAllUnprocessedInputs, + updateLocalUnprocessedInputs, + unprocessedLabels, + unprocessedNotes, +} from '../js/diary/timelineHelper'; +import { mockBEMUserCache } from '../__mocks__/cordovaMocks'; +import * as mockTLH from '../__mocks__/timelineHelperMocks'; +import { GeoJSONData, GeoJSONStyledFeature } from '../js/types/diaryTypes'; + +mockLogger(); +mockAlert(); +mockBEMUserCache(); + +beforeEach(() => { + clearAlerts(); +}); + +afterAll(() => { + jest.restoreAllMocks(); +}); + +describe('useGeojsonForTrip', () => { + it('work with an empty input', () => { + const testVal = useGeojsonForTrip({} as any); + expect(testVal).toBeFalsy; + }); + + const checkGeojson = (geoObj: GeoJSONData) => { + expect(geoObj.data).toEqual( + expect.objectContaining({ + id: expect.any(String), + type: 'FeatureCollection', + features: expect.any(Array), + }), + ); + }; + + it('works without labelMode flag', () => { + const testValue = useGeojsonForTrip(mockTLH.mockCompDataTwo.phone_data[1].data) as GeoJSONData; + expect(testValue).toBeTruthy; + checkGeojson(testValue); + expect(testValue.data.features.length).toBe(3); + }); +}); + +describe('compositeTrips2TimelineMap', () => { + const tripListOne = [mockTLH.mockCompData.phone_data[0].data]; + const tripListTwo = [ + mockTLH.mockCompDataTwo.phone_data[0].data, + mockTLH.mockCompDataTwo.phone_data[1].data, + ]; + const keyOne = mockTLH.mockCompData.phone_data[0].data._id.$oid; + const keyTwo = mockTLH.mockCompDataTwo.phone_data[1].data._id.$oid; + const keyThree = mockTLH.mockCompData.phone_data[0].data._id.$oid; + let testValue; + + it('Works with an empty list', () => { + expect(Object.keys(compositeTrips2TimelineMap([])).length).toBe(0); + }); + + it('Works with a list of len = 1, no flag', () => { + testValue = compositeTrips2TimelineMap(tripListOne); + expect(testValue.size).toBe(1); + expect(testValue.get(keyOne)).toEqual(tripListOne[0]); + }); + + it('Works with a list of len = 1, with flag', () => { + testValue = compositeTrips2TimelineMap(tripListOne, true); + expect(testValue.size).toBe(3); + expect(testValue.get(keyOne)).toEqual(tripListOne[0]); + expect(testValue.get('startConfirmedPlace')).toEqual(tripListOne[0].start_confirmed_place); + expect(testValue.get('endConfirmedPlace')).toEqual(tripListOne[0].end_confirmed_place); + }); + + it('Works with a list of len >= 1, no flag', () => { + testValue = compositeTrips2TimelineMap(tripListTwo); + expect(testValue.size).toBe(2); + expect(testValue.get(keyTwo)).toEqual(tripListTwo[1]); + expect(testValue.get(keyThree)).toEqual(tripListTwo[0]); + }); + + it('Works with a list of len >= 1, with flag', () => { + testValue = compositeTrips2TimelineMap(tripListTwo, true); + expect(testValue.size).toBe(6); + for (const [key, value] of Object.entries(testValue)) { + expect(value).toBe(tripListTwo[0][key] || tripListTwo[1][key]); + } + }); +}); + +it('use an appConfig to get labelInputKeys', () => { + expect(keysForLabelInputs(mockTLH.mockConfigEnketo)).toEqual(['manual/trip_user_input']); + expect(keysForLabelInputs(mockTLH.mockConfigModeOfStudy).length).toEqual(3); +}); + +// updateUnprocessedInputs Tests +jest.mock('../js/survey/multilabel/confirmHelper', () => ({ + ...jest.requireActual('../js/survey/multilabel/confirmHelper'), + getLabelInputs: jest.fn(() => ['MODE', 'PURPOSE', 'REPLACED_MODE']), +})); + +describe('unprocessedLabels, unprocessedNotes', () => { + it('has no labels or notes when nothing has been recorded', async () => { + await updateAllUnprocessedInputs({ start_ts: 0, end_ts: 99 }, mockTLH.mockConfigNoModeOfStudy); + Object.values(unprocessedLabels).forEach((value) => { + expect(value).toEqual([]); + }); + expect(unprocessedNotes).toEqual([]); + }); + + it('has some mode and purpose labels after they were just recorded', async () => { + // record some labels + await window['cordova'].plugins.BEMUserCache.putMessage('manual/mode_confirm', { + start_ts: 2, + end_ts: 3, + label: 'tricycle', + }); + await window['cordova'].plugins.BEMUserCache.putMessage('manual/purpose_confirm', { + start_ts: 2, + end_ts: 3, + label: 'shopping', + }); + + // update unprocessed inputs and check that the new labels show up in unprocessedLabels + await updateLocalUnprocessedInputs({ start_ts: 2, end_ts: 3 }, mockTLH.mockConfigNoModeOfStudy); + expect(unprocessedLabels['MODE'].length).toEqual(1); + expect(unprocessedLabels['MODE'][0].data.label).toEqual('tricycle'); + expect(unprocessedLabels['PURPOSE'].length).toEqual(1); + expect(unprocessedLabels['PURPOSE'][0].data.label).toEqual('shopping'); + }); + + it('has some trip- and place- survey responses after they were just recorded', async () => { + // record two survey responses, one for trip_user_input and one for place_user_input + const tripSurveyResponse = { + start_ts: 4, + end_ts: 5, + name: 'TripConfirmSurvey', // for now, the name of this survey must be hardcoded (see note in UserInputButton.tsx) + version: 1.2, + label: '1 foobar', + match_id: 'd263935e-9163-4072-9909-9d3e1edb31be', + key: 'manual/trip_user_input', + xmlResponse: ` 2023-12-04T12:12:38.968-05:00 2023-12-04T12:12:38.970-05:00 bar uuid:75dc7b18-2a9d-4356-b66e-d63dfa7568ca `, + }; + const placeSurveyResponse = { + ...tripSurveyResponse, + start_ts: 5, + end_ts: 6, + key: 'manual/place_user_input', + }; + await window['cordova'].plugins.BEMUserCache.putMessage( + 'manual/trip_user_input', + tripSurveyResponse, + ); + await window['cordova'].plugins.BEMUserCache.putMessage( + 'manual/place_user_input', + placeSurveyResponse, + ); + + // update unprocessed inputs and check that the trip survey response shows up in unprocessedLabels + await updateAllUnprocessedInputs({ start_ts: 4, end_ts: 6 }, mockTLH.mockConfigEnketo); + expect(unprocessedLabels['SURVEY'][0].data).toEqual(tripSurveyResponse); + // the second response is ignored for now because we haven't enabled place_user_input yet + // so the length is only 1 + expect(unprocessedLabels['SURVEY'].length).toEqual(1); + }); + + it('has some trip- and place- level additions after they were just recorded', async () => { + // record two additions, one for trip_addition_input and one for place_addition_input + const tripAdditionOne = { + start_ts: 6, + end_ts: 7, + key: 'manual/trip_addition_input', + data: { foo: 'bar' }, + }; + const tripAdditionTwo = { + ...tripAdditionOne, + data: { foo: 'baz' }, + }; + const placeAdditionOne = { + ...tripAdditionOne, + start_ts: 7, + end_ts: 8, + key: 'manual/place_addition_input', + }; + const placeAdditionTwo = { + ...placeAdditionOne, + data: { foo: 'baz' }, + }; + Promise.all([ + window['cordova'].plugins.BEMUserCache.putMessage( + 'manual/trip_addition_input', + tripAdditionOne, + ), + window['cordova'].plugins.BEMUserCache.putMessage( + 'manual/place_addition_input', + tripAdditionTwo, + ), + window['cordova'].plugins.BEMUserCache.putMessage( + 'manual/trip_addition_input', + placeAdditionOne, + ), + window['cordova'].plugins.BEMUserCache.putMessage( + 'manual/place_addition_input', + placeAdditionTwo, + ), + ]).then(() => { + // update unprocessed inputs and check that all additions show up in unprocessedNotes + updateAllUnprocessedInputs({ start_ts: 6, end_ts: 8 }, mockTLH.mockConfigEnketo); + expect(unprocessedLabels['NOTES'].length).toEqual(4); + expect(unprocessedLabels['NOTES'][0].data).toEqual(tripAdditionOne); + expect(unprocessedLabels['NOTES'][1].data).toEqual(tripAdditionTwo); + expect(unprocessedLabels['NOTES'][2].data).toEqual(placeAdditionOne); + expect(unprocessedLabels['NOTES'][3].data).toEqual(placeAdditionTwo); + }); + }); +}); + +// Tests for readAllCompositeTrips +// Once we have end-to-end testing, we could utilize getRawEnteries. +jest.mock('../js/services/commHelper', () => ({ + getRawEntries: jest.fn((key, startTs, endTs, valTwo) => { + if (startTs === mockTLH.fakeStartTsOne) return mockTLH.mockCompData; + if (startTs == mockTLH.fakeStartTsTwo) return mockTLH.mockCompDataTwo; + // the original implementation of `getRawEntries` for all other inputs + return jest + .requireActual('../js/services/commHelper') + .getRawEntries(key, startTs, endTs, valTwo); + }), +})); + +it('works when there are no composite trip objects fetched', async () => { + expect(readAllCompositeTrips(-1, -1)).resolves.toEqual([]); +}); + +// Checks that `readAllCompositeTrips` properly unpacks & flattens the confirmedPlaces +const checkTripIsUnpacked = (obj) => { + expect(obj.metadata).toBeUndefined(); + expect(obj).toEqual( + expect.objectContaining({ + key: expect.any(String), + origin_key: expect.any(String), + start_confirmed_place: expect.objectContaining({ + origin_key: expect.any(String), + }), + end_confirmed_place: expect.objectContaining({ + origin_key: expect.any(String), + }), + locations: expect.any(Array), + sections: expect.any(Array), + }), + ); +}; + +it('fetches a composite trip object and collapses it', async () => { + const testValue = await readAllCompositeTrips(mockTLH.fakeStartTsOne, mockTLH.fakeEndTsOne); + expect(testValue.length).toEqual(1); + checkTripIsUnpacked(testValue[0]); +}); + +it('Works with multiple trips', async () => { + const testValue = await readAllCompositeTrips(mockTLH.fakeStartTsTwo, mockTLH.fakeEndTsTwo); + expect(testValue.length).toEqual(2); + checkTripIsUnpacked(testValue[0]); + checkTripIsUnpacked(testValue[1]); + expect(testValue[0].origin_key).toBe('1'); + expect(testValue[1].origin_key).toBe('2'); +}); + +// Tests for `readUnprocessedTrips` +jest.mock('../js/services/unifiedDataLoader', () => ({ + getUnifiedDataForInterval: jest.fn((key, tq, combiner) => { + if (key === 'statemachine/transition') { + if (tq.startTs === mockTLH.fakeStartTsOne) return Promise.resolve(mockTLH.mockTransitions); + return Promise.resolve([]); + } + if (key === 'background/filtered_location') { + return Promise.resolve(mockTLH.mockFilterLocations); + } + // the original implementation of `getUnifiedDataForInterval` for other keys + return jest + .requireActual('../js/services/unifiedDataLoader') + .getUnifiedDataForInterval(key, tq, combiner); + }), +})); + +it('works when there are no unprocessed trips...', async () => { + expect(readUnprocessedTrips(-1, -1, {} as any)).resolves.toEqual([]); +}); + +it('works when there are one or more unprocessed trips...', async () => { + const testValueOne = await readUnprocessedTrips( + mockTLH.fakeStartTsOne, + mockTLH.fakeEndTsOne, + {} as any, + ); + expect(testValueOne.length).toEqual(1); + expect(testValueOne[0]).toEqual( + expect.objectContaining({ + origin_key: expect.any(String), + distance: expect.any(Number), + start_loc: expect.objectContaining({ + type: expect.any(String), + coordinates: expect.any(Array), + }), + }), + ); +}); diff --git a/www/__tests__/unifiedDataLoader.test.ts b/www/__tests__/unifiedDataLoader.test.ts new file mode 100644 index 000000000..7916cfde1 --- /dev/null +++ b/www/__tests__/unifiedDataLoader.test.ts @@ -0,0 +1,150 @@ +import { mockLogger } from '../__mocks__/globalMocks'; +import { removeDup, combinedPromises } from '../js/services/unifiedDataLoader'; +import { LocalDt, BEMData } from '../js/types/serverData'; + +mockLogger(); + +const testOne: BEMData = { + data: '', + metadata: { + key: '', + platform: '', + write_ts: 1, // the only value checked by removeDup + time_zone: '', + write_fmt_time: '', + write_local_dt: {} as LocalDt, + }, +}; + +const testTwo = JSON.parse(JSON.stringify(testOne)); +testTwo.metadata.write_ts = 2; +const testThree = JSON.parse(JSON.stringify(testOne)); +testThree.metadata.write_ts = 3; +const testFour = JSON.parse(JSON.stringify(testOne)); +testFour.metadata.write_ts = 4; + +describe('removeDup can', () => { + it('work with an empty array', () => { + expect(removeDup([])).toEqual([]); + }); + + it('work with an array of len 1', () => { + expect(removeDup([testOne])).toEqual([testOne]); + }); + + it('work with an array of len >=1', () => { + expect(removeDup([testOne, testTwo])).toEqual([testOne, testTwo]); + expect(removeDup([testOne, testOne])).toEqual([testOne]); + expect(removeDup([testOne, testTwo, testThree])).toEqual([testOne, testTwo, testThree]); + expect(removeDup([testOne, testOne, testThree])).toEqual([testOne, testThree]); + expect(removeDup([testOne, testOne, testOne])).toEqual([testOne]); + }); +}); + +// combinedPromises tests +const promiseGenerator = (values: Array>) => { + return Promise.resolve(values); +}; +const badPromiseGenerator = (input: string) => { + return Promise.reject(input); +}; + +it('throws an error on an empty input', async () => { + expect(() => { + combinedPromises([], removeDup); + }).toThrow(); +}); + +it('catches when all promises fails', async () => { + expect(combinedPromises([badPromiseGenerator('')], removeDup)).rejects.toEqual(['']); + expect( + combinedPromises([badPromiseGenerator('bad'), badPromiseGenerator('promise')], removeDup), + ).rejects.toEqual(['bad', 'promise']); + expect( + combinedPromises( + [badPromiseGenerator('very'), badPromiseGenerator('bad'), badPromiseGenerator('promise')], + removeDup, + ), + ).rejects.toEqual(['very', 'bad', 'promise']); + + expect( + combinedPromises([badPromiseGenerator('bad'), promiseGenerator([testOne])], removeDup), + ).resolves.toEqual([testOne]); + expect( + combinedPromises([promiseGenerator([testOne]), badPromiseGenerator('bad')], removeDup), + ).resolves.toEqual([testOne]); +}); + +it('work with arrays of len 1', async () => { + const promiseArrayOne = [promiseGenerator([testOne])]; + const promiseArrayTwo = [promiseGenerator([testOne, testTwo])]; + const testResultOne = await combinedPromises(promiseArrayOne, removeDup); + const testResultTwo = await combinedPromises(promiseArrayTwo, removeDup); + + expect(testResultOne).toEqual([testOne]); + expect(testResultTwo).toEqual([testOne, testTwo]); +}); + +it('works with arrays of len 2', async () => { + const promiseArrayOne = [promiseGenerator([testOne]), promiseGenerator([testTwo])]; + const promiseArrayTwo = [promiseGenerator([testOne, testTwo]), promiseGenerator([testThree])]; + const promiseArrayThree = [promiseGenerator([testOne]), promiseGenerator([testTwo, testThree])]; + const promiseArrayFour = [ + promiseGenerator([testOne, testTwo]), + promiseGenerator([testThree, testFour]), + ]; + const promiseArrayFive = [ + promiseGenerator([testOne, testTwo]), + promiseGenerator([testTwo, testThree]), + ]; + + const testResultOne = await combinedPromises(promiseArrayOne, removeDup); + const testResultTwo = await combinedPromises(promiseArrayTwo, removeDup); + const testResultThree = await combinedPromises(promiseArrayThree, removeDup); + const testResultFour = await combinedPromises(promiseArrayFour, removeDup); + const testResultFive = await combinedPromises(promiseArrayFive, removeDup); + + expect(testResultOne).toEqual([testOne, testTwo]); + expect(testResultTwo).toEqual([testOne, testTwo, testThree]); + expect(testResultThree).toEqual([testOne, testTwo, testThree]); + expect(testResultFour).toEqual([testOne, testTwo, testThree, testFour]); + expect(testResultFive).toEqual([testOne, testTwo, testThree]); +}); + +it('works with arrays of len >= 2', async () => { + const promiseArrayOne = [ + promiseGenerator([testOne]), + promiseGenerator([testTwo]), + promiseGenerator([testThree]), + ]; + const promiseArrayTwo = [ + promiseGenerator([testOne]), + promiseGenerator([testTwo]), + promiseGenerator([testTwo]), + ]; + const promiseArrayThree = [ + promiseGenerator([testOne]), + promiseGenerator([testTwo]), + promiseGenerator([testThree, testFour]), + ]; + const promiseArrayFour = [ + promiseGenerator([testOne]), + promiseGenerator([testTwo, testThree]), + promiseGenerator([testFour]), + ]; + + const testResultOne = await combinedPromises(promiseArrayOne, removeDup); + const testResultTwo = await combinedPromises(promiseArrayTwo, removeDup); + const testResultThree = await combinedPromises(promiseArrayThree, removeDup); + const testResultFour = await combinedPromises(promiseArrayFour, removeDup); + + expect(testResultOne).toEqual([testOne, testTwo, testThree]); + expect(testResultTwo).toEqual([testOne, testTwo]); + expect(testResultThree).toEqual([testOne, testTwo, testThree, testFour]); + expect(testResultFour).toEqual([testOne, testTwo, testThree, testFour]); +}); + +/* + TO-DO: Once getRawEnteries can be tested via end-to-end testing, we will be able to + test getUnifiedDataForInterval as well. +*/ diff --git a/www/__tests__/uploadService.test.ts b/www/__tests__/uploadService.test.ts new file mode 100644 index 000000000..b9bede9fd --- /dev/null +++ b/www/__tests__/uploadService.test.ts @@ -0,0 +1,56 @@ +//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(); +mockGetAppVersion(); +mockCordova(); + +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 = ''; + +//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; + +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); diff --git a/www/__tests__/useImperialConfig.test.ts b/www/__tests__/useImperialConfig.test.ts new file mode 100644 index 000000000..593498aae --- /dev/null +++ b/www/__tests__/useImperialConfig.test.ts @@ -0,0 +1,55 @@ +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, + }, + loading: false, + })); +}); + +describe('formatForDisplay', () => { + it('should round to the nearest integer when value is >= 100', () => { + expect(formatForDisplay(105)).toBe('105'); + expect(formatForDisplay(119.01)).toBe('119'); + expect(formatForDisplay(119.91)).toBe('120'); + }); + + it('should round to 3 significant digits when 1 <= value < 100', () => { + expect(formatForDisplay(7.02)).toBe('7.02'); + expect(formatForDisplay(9.6262)).toBe('9.63'); + expect(formatForDisplay(11.333)).toBe('11.3'); + expect(formatForDisplay(99.99)).toBe('100'); + }); + + it('should round to 2 decimal places when value < 1', () => { + expect(formatForDisplay(0.07178)).toBe('0.07'); + expect(formatForDisplay(0.08978)).toBe('0.09'); + expect(formatForDisplay(0.75)).toBe('0.75'); + expect(formatForDisplay(0.001)).toBe('0'); + expect(formatForDisplay(0.006)).toBe('0.01'); + expect(formatForDisplay(0.00001)).toBe('0'); + }); +}); + +describe('convertDistance', () => { + it('should convert meters to kilometers by default', () => { + expect(convertDistance(1000, false)).toBe(1); + }); + + it('should convert meters to miles when imperial flag is true', () => { + expect(convertDistance(1609.34, true)).toBeCloseTo(1); // Approximately 1 mile + }); +}); + +describe('convertSpeed', () => { + it('should convert meters per second to kilometers per hour by default', () => { + expect(convertSpeed(10, false)).toBe(36); + }); + + it('should convert meters per second to miles per hour when imperial flag is true', () => { + expect(convertSpeed(6.7056, true)).toBeCloseTo(15); // Approximately 15 mph + }); +}); diff --git a/www/build/app.css b/www/build/app.css deleted file mode 100644 index d7ec98c10..000000000 --- a/www/build/app.css +++ /dev/null @@ -1,9814 +0,0 @@ -/* ======================================================================== - * bootstrap-tour - v0.10.1 - * http://bootstraptour.com - * ======================================================================== - * Copyright 2012-2013 Ulrich Sossou - * - * ======================================================================== - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * ======================================================================== - */ - -.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} -/*! - * angular-loading-bar v0.6.0 - * https://chieffancypants.github.io/angular-loading-bar - * Copyright (c) 2014 Wes Cruver - * License: MIT - */ - -/* Make clicks pass-through */ -#loading-bar, -#loading-bar-spinner { - pointer-events: none; - -webkit-pointer-events: none; - -webkit-transition: 350ms linear all; - -moz-transition: 350ms linear all; - -o-transition: 350ms linear all; - transition: 350ms linear all; -} - -#loading-bar.ng-enter, -#loading-bar.ng-leave.ng-leave-active, -#loading-bar-spinner.ng-enter, -#loading-bar-spinner.ng-leave.ng-leave-active { - opacity: 0; -} - -#loading-bar.ng-enter.ng-enter-active, -#loading-bar.ng-leave, -#loading-bar-spinner.ng-enter.ng-enter-active, -#loading-bar-spinner.ng-leave { - opacity: 1; -} - -#loading-bar .bar { - -webkit-transition: width 350ms; - -moz-transition: width 350ms; - -o-transition: width 350ms; - transition: width 350ms; - - background: #29d; - position: fixed; - z-index: 10002; - top: 0; - left: 0; - width: 100%; - height: 2px; - border-bottom-right-radius: 1px; - border-top-right-radius: 1px; -} - -/* Fancy blur effect */ -#loading-bar .peg { - position: absolute; - width: 70px; - right: 0; - top: 0; - height: 2px; - opacity: .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; - box-shadow: #29d 1px 0 6px 1px; - -moz-border-radius: 100%; - -webkit-border-radius: 100%; - border-radius: 100%; -} - -#loading-bar-spinner { - display: block; - position: fixed; - z-index: 10002; - top: 10px; - left: 10px; -} - -#loading-bar-spinner .spinner-icon { - width: 14px; - height: 14px; - - 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; -} - -@-webkit-keyframes loading-bar-spinner { - 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); } -} -@-o-keyframes loading-bar-spinner { - 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); } -} -@keyframes loading-bar-spinner { - 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; -} - -.select2-container, -.select2-drop, -.select2-search, -.select2-search input { - /* - Force border-box so that % widths fit the parent - container without overlap because of margin/padding. - 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 */ -} - -.select2-container .select2-choice { - 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-radius: 4px; - - background-clip: padding-box; - - -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%); -} - -.select2-container.select2-drop-above .select2-choice { - border-bottom-color: #aaa; - - 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%); -} - -.select2-container.select2-allowclear .select2-choice .select2-chosen { - margin-right: 42px; -} - -.select2-container .select2-choice > .select2-chosen { - margin-right: 26px; - display: block; - overflow: hidden; - - white-space: nowrap; - - text-overflow: ellipsis; - float: none; - width: auto; -} - -.select2-container .select2-choice abbr { - display: none; - width: 12px; - height: 12px; - position: absolute; - right: 24px; - top: 8px; - - font-size: 1px; - text-decoration: none; - - border: 0; - background: url('select2.png') right top no-repeat; - cursor: pointer; - outline: 0; -} - -.select2-container.select2-allowclear .select2-choice abbr { - display: inline-block; -} - -.select2-container .select2-choice abbr:hover { - 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); -} - -.select2-drop { - width: 100%; - margin-top: -1px; - position: absolute; - z-index: 9999; - top: 100%; - - background: #fff; - color: #000; - border: 1px solid #aaa; - border-top: 0; - - 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); -} - -.select2-drop.select2-drop-above { - margin-top: 1px; - border-top: 1px solid #aaa; - border-bottom: 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); -} - -.select2-drop-active { - border: 1px solid #5897fb; - border-top: none; -} - -.select2-drop.select2-drop-above.select2-drop-active { - border-top: 1px solid #5897fb; -} - -.select2-drop-auto-width { - border-top: 1px solid #aaa; - width: auto; -} - -.select2-drop-auto-width .select2-search { - padding-top: 4px; -} - -.select2-container .select2-choice .select2-arrow { - display: inline-block; - width: 18px; - height: 100%; - position: absolute; - right: 0; - top: 0; - - border-left: 1px solid #aaa; - border-radius: 0 4px 4px 0; - - 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%); -} - -.select2-container .select2-choice .select2-arrow b { - 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; - - position: relative; - z-index: 10000; - - white-space: nowrap; -} - -.select2-search input { - width: 100%; - height: auto !important; - min-height: 26px; - padding: 4px 20px 4px 5px; - margin: 0; - - outline: 0; - font-family: sans-serif; - font-size: 1em; - - border: 1px solid #aaa; - border-radius: 0; - - -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; -} - -.select2-drop.select2-drop-above .select2-search input { - 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; -} - -.select2-container-active .select2-choice, -.select2-container-active .select2-choices { - 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); -} - -.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-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%); -} - -.select2-dropdown-open.select2-drop-above .select2-choice, -.select2-dropdown-open.select2-drop-above .select2-choices { - 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%); -} - -.select2-dropdown-open .select2-choice .select2-arrow { - background: transparent; - border-left: none; - filter: none; -} -.select2-dropdown-open .select2-choice .select2-arrow b { - 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; -} - -/* 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); -} - -.select2-results ul.select2-result-sub { - margin: 0; - padding-left: 0; -} - -.select2-results li { - list-style: none; - display: list-item; - background-image: none; -} - -.select2-results li.select2-result-with-children > .select2-result-label { - font-weight: bold; -} - -.select2-results .select2-result-label { - padding: 3px 7px 4px; - margin: 0; - cursor: pointer; - - min-height: 1em; - - -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 .select2-highlighted { - background: #3875d7; - color: #fff; -} - -.select2-results li em { - background: #feffde; - font-style: normal; -} - -.select2-results .select2-highlighted em { - background: transparent; -} - -.select2-results .select2-highlighted ul { - 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; -} - -/* -disabled look for disabled choices in the results dropdown -*/ -.select2-results .select2-disabled.select2-highlighted { - color: #666; - background: #f4f4f4; - display: list-item; - cursor: default; -} -.select2-results .select2-disabled { - background: #f4f4f4; - display: list-item; - cursor: default; -} - -.select2-results .select2-selected { - display: none; -} - -.select2-more-results.select2-active { - background: #f4f4f4 url('select2-spinner.gif') no-repeat 100%; -} - -.select2-more-results { - 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; -} - -.select2-container.select2-container-disabled .select2-choice .select2-arrow { - background-color: #f4f4f4; - background-image: none; - border-left: 0; -} - -.select2-container.select2-container-disabled .select2-choice abbr { - display: none; -} - - -/* multiselect */ - -.select2-container-multi .select2-choices { - height: auto !important; - height: 1%; - margin: 0; - padding: 0; - position: relative; - - 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%); -} - -.select2-locked { - padding: 3px 5px 3px 5px !important; -} - -.select2-container-multi .select2-choices { - min-height: 26px; -} - -.select2-container-multi.select2-container-active .select2-choices { - 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); -} -.select2-container-multi .select2-choices li { - float: left; - list-style: none; -} -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; -} - -.select2-container-multi .select2-choices .select2-search-field input { - 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; -} - -.select2-container-multi .select2-choices .select2-search-field input.select2-active { - background: #fff url('select2-spinner.gif') no-repeat 100% !important; -} - -.select2-default { - color: #999 !important; -} - -.select2-container-multi .select2-choices .select2-search-choice { - padding: 3px 5px 3px 18px; - margin: 3px 0 3px 5px; - position: relative; - - line-height: 13px; - color: #333; - cursor: default; - border: 1px solid #aaaaaa; - - 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); - - background-clip: padding-box; - - -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; -} -.select2-container-multi .select2-choices .select2-search-choice .select2-chosen { - cursor: default; -} -.select2-container-multi .select2-choices .select2-search-choice-focus { - background: #d4d4d4; -} - -.select2-search-choice-close { - 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; -} -html[dir="rtl"] .select2-search-choice-close { - right: auto; - left: 3px; -} - -.select2-container-multi .select2-search-choice-close { - left: 3px; -} - -.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; -} - -/* disabled styles */ -.select2-container-multi.select2-container-disabled .select2-choices { - 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; -} - -.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; -} - -.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; -} - -.select2-measure-scrollbar { - 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; - } - - .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: ""; - display: table; - clear: both; -} -.task-add, -.plusminus .task-checker, -.task-options .priority-multiplier li, -.task-options .task-attributes li, -.task-options .repeat-days li, -.task-options .repeat-frequency li, -.task-options .save-close, -.task-checklist-edit > ul li, -.task-checklist-edit > .checklist-form li, -.task-filter li, -.filters .filters-controls li, -.filters .filters-tags, -.filters .hrpg-input-group, -.chat-buttons, -.toolbar-mobile, -.toolbar-nav .toolbar-button-dropdown, -.toolbar-nav .toolbar-button, -.toolbar-sync, -.toolbar-settings, -.toolbar-audio, -.toolbar-notifs, -.toolbar-controls .toolbar-controls-button, -.toolbar-subscribe-button, -.toolbar-controls .toolbar-subscribe-button, -.options-menu li, -.options-submenu li { - list-style: none; -} -.task-add > a, -.plusminus .task-checker > a, -.task-options .priority-multiplier li > a, -.task-options .task-attributes li > a, -.task-options .repeat-days li > a, -.task-options .repeat-frequency li > a, -.task-options .save-close > a, -.task-checklist-edit > ul li > a, -.task-checklist-edit > .checklist-form li > a, -.task-filter li > a, -.filters .filters-controls li > a, -.filters .filters-tags > a, -.filters .hrpg-input-group > a, -.chat-buttons > a, -.toolbar-mobile > a, -.toolbar-nav .toolbar-button-dropdown > a, -.toolbar-nav .toolbar-button > a, -.toolbar-sync > a, -.toolbar-settings > a, -.toolbar-audio > a, -.toolbar-notifs > a, -.toolbar-controls .toolbar-controls-button > a, -.toolbar-subscribe-button > a, -.toolbar-controls .toolbar-subscribe-button > a, -.options-menu li > a, -.options-submenu li > a, -.task-add > button, -.plusminus .task-checker > button, -.task-options .priority-multiplier li > button, -.task-options .task-attributes li > button, -.task-options .repeat-days li > button, -.task-options .repeat-frequency li > button, -.task-options .save-close > button, -.task-checklist-edit > ul li > button, -.task-checklist-edit > .checklist-form li > button, -.task-filter li > button, -.filters .filters-controls li > button, -.filters .filters-tags > button, -.filters .hrpg-input-group > button, -.chat-buttons > button, -.toolbar-mobile > button, -.toolbar-nav .toolbar-button-dropdown > button, -.toolbar-nav .toolbar-button > button, -.toolbar-sync > button, -.toolbar-settings > button, -.toolbar-audio > button, -.toolbar-notifs > button, -.toolbar-controls .toolbar-controls-button > button, -.toolbar-subscribe-button > button, -.toolbar-controls .toolbar-subscribe-button > button, -.options-menu li > button, -.options-submenu li > button, -.task-add > input, -.plusminus .task-checker > input, -.task-options .priority-multiplier li > input, -.task-options .task-attributes li > input, -.task-options .repeat-days li > input, -.task-options .repeat-frequency li > input, -.task-options .save-close > input, -.task-checklist-edit > ul li > input, -.task-checklist-edit > .checklist-form li > input, -.task-filter li > input, -.filters .filters-controls li > input, -.filters .filters-tags > input, -.filters .hrpg-input-group > input, -.chat-buttons > input, -.toolbar-mobile > input, -.toolbar-nav .toolbar-button-dropdown > input, -.toolbar-nav .toolbar-button > input, -.toolbar-sync > input, -.toolbar-settings > input, -.toolbar-audio > input, -.toolbar-notifs > input, -.toolbar-controls .toolbar-controls-button > input, -.toolbar-subscribe-button > input, -.toolbar-controls .toolbar-subscribe-button > input, -.options-menu li > input, -.options-submenu li > input, -.task-add label::after, -.plusminus .task-checker label::after, -.task-options .priority-multiplier li label::after, -.task-options .task-attributes li label::after, -.task-options .repeat-days li label::after, -.task-options .repeat-frequency li label::after, -.task-options .save-close label::after, -.task-checklist-edit > ul li label::after, -.task-checklist-edit > .checklist-form li label::after, -.task-filter li label::after, -.filters .filters-controls li label::after, -.filters .filters-tags label::after, -.filters .hrpg-input-group label::after, -.chat-buttons label::after, -.toolbar-mobile label::after, -.toolbar-nav .toolbar-button-dropdown label::after, -.toolbar-nav .toolbar-button label::after, -.toolbar-sync label::after, -.toolbar-settings label::after, -.toolbar-audio label::after, -.toolbar-notifs label::after, -.toolbar-controls .toolbar-controls-button label::after, -.toolbar-subscribe-button label::after, -.toolbar-controls .toolbar-subscribe-button label::after, -.options-menu li label::after, -.options-submenu li label::after { - display: inline-block !important; - padding: 0.25em 0.5em !important; - text-decoration: none; - font-size: 1em; - color: #222; - background-color: transparent; -} -.task-add > a .glyphicon, -.plusminus .task-checker > a .glyphicon, -.task-options .priority-multiplier li > a .glyphicon, -.task-options .task-attributes li > a .glyphicon, -.task-options .repeat-days li > a .glyphicon, -.task-options .repeat-frequency li > a .glyphicon, -.task-options .save-close > a .glyphicon, -.task-checklist-edit > ul li > a .glyphicon, -.task-checklist-edit > .checklist-form li > a .glyphicon, -.task-filter li > a .glyphicon, -.filters .filters-controls li > a .glyphicon, -.filters .filters-tags > a .glyphicon, -.filters .hrpg-input-group > a .glyphicon, -.chat-buttons > a .glyphicon, -.toolbar-mobile > a .glyphicon, -.toolbar-nav .toolbar-button-dropdown > a .glyphicon, -.toolbar-nav .toolbar-button > a .glyphicon, -.toolbar-sync > a .glyphicon, -.toolbar-settings > a .glyphicon, -.toolbar-audio > a .glyphicon, -.toolbar-notifs > a .glyphicon, -.toolbar-controls .toolbar-controls-button > a .glyphicon, -.toolbar-subscribe-button > a .glyphicon, -.toolbar-controls .toolbar-subscribe-button > a .glyphicon, -.options-menu li > a .glyphicon, -.options-submenu li > a .glyphicon, -.task-add > button .glyphicon, -.plusminus .task-checker > button .glyphicon, -.task-options .priority-multiplier li > button .glyphicon, -.task-options .task-attributes li > button .glyphicon, -.task-options .repeat-days li > button .glyphicon, -.task-options .repeat-frequency li > button .glyphicon, -.task-options .save-close > button .glyphicon, -.task-checklist-edit > ul li > button .glyphicon, -.task-checklist-edit > .checklist-form li > button .glyphicon, -.task-filter li > button .glyphicon, -.filters .filters-controls li > button .glyphicon, -.filters .filters-tags > button .glyphicon, -.filters .hrpg-input-group > button .glyphicon, -.chat-buttons > button .glyphicon, -.toolbar-mobile > button .glyphicon, -.toolbar-nav .toolbar-button-dropdown > button .glyphicon, -.toolbar-nav .toolbar-button > button .glyphicon, -.toolbar-sync > button .glyphicon, -.toolbar-settings > button .glyphicon, -.toolbar-audio > button .glyphicon, -.toolbar-notifs > button .glyphicon, -.toolbar-controls .toolbar-controls-button > button .glyphicon, -.toolbar-subscribe-button > button .glyphicon, -.toolbar-controls .toolbar-subscribe-button > button .glyphicon, -.options-menu li > button .glyphicon, -.options-submenu li > button .glyphicon, -.task-add > input .glyphicon, -.plusminus .task-checker > input .glyphicon, -.task-options .priority-multiplier li > input .glyphicon, -.task-options .task-attributes li > input .glyphicon, -.task-options .repeat-days li > input .glyphicon, -.task-options .repeat-frequency li > input .glyphicon, -.task-options .save-close > input .glyphicon, -.task-checklist-edit > ul li > input .glyphicon, -.task-checklist-edit > .checklist-form li > input .glyphicon, -.task-filter li > input .glyphicon, -.filters .filters-controls li > input .glyphicon, -.filters .filters-tags > input .glyphicon, -.filters .hrpg-input-group > input .glyphicon, -.chat-buttons > input .glyphicon, -.toolbar-mobile > input .glyphicon, -.toolbar-nav .toolbar-button-dropdown > input .glyphicon, -.toolbar-nav .toolbar-button > input .glyphicon, -.toolbar-sync > input .glyphicon, -.toolbar-settings > input .glyphicon, -.toolbar-audio > input .glyphicon, -.toolbar-notifs > input .glyphicon, -.toolbar-controls .toolbar-controls-button > input .glyphicon, -.toolbar-subscribe-button > input .glyphicon, -.toolbar-controls .toolbar-subscribe-button > input .glyphicon, -.options-menu li > input .glyphicon, -.options-submenu li > input .glyphicon, -.task-add label::after .glyphicon, -.plusminus .task-checker label::after .glyphicon, -.task-options .priority-multiplier li label::after .glyphicon, -.task-options .task-attributes li label::after .glyphicon, -.task-options .repeat-days li label::after .glyphicon, -.task-options .repeat-frequency li label::after .glyphicon, -.task-options .save-close label::after .glyphicon, -.task-checklist-edit > ul li label::after .glyphicon, -.task-checklist-edit > .checklist-form li label::after .glyphicon, -.task-filter li label::after .glyphicon, -.filters .filters-controls li label::after .glyphicon, -.filters .filters-tags label::after .glyphicon, -.filters .hrpg-input-group label::after .glyphicon, -.chat-buttons label::after .glyphicon, -.toolbar-mobile label::after .glyphicon, -.toolbar-nav .toolbar-button-dropdown label::after .glyphicon, -.toolbar-nav .toolbar-button label::after .glyphicon, -.toolbar-sync label::after .glyphicon, -.toolbar-settings label::after .glyphicon, -.toolbar-audio label::after .glyphicon, -.toolbar-notifs label::after .glyphicon, -.toolbar-controls .toolbar-controls-button label::after .glyphicon, -.toolbar-subscribe-button label::after .glyphicon, -.toolbar-controls .toolbar-subscribe-button label::after .glyphicon, -.options-menu li label::after .glyphicon, -.options-submenu li label::after .glyphicon { - position: relative; - top: 0.132em; -} -> a:active, -> button:active { - background-color: #aaa !important; -} -.plusminus .task-checker > a, -.task-options .priority-multiplier li > a, -.task-options .task-attributes li > a, -.task-options .repeat-days li > a, -.task-options .repeat-frequency li > a, -.task-options .save-close > a, -.task-checklist-edit > ul li > a, -.filters .filters-tags > a, -.chat-buttons > a, -.toolbar-mobile > a, -.toolbar-nav .toolbar-button > a, -.toolbar-sync > a, -.toolbar-settings > a, -.toolbar-audio > a, -.toolbar-notifs > a, -.toolbar-controls .toolbar-controls-button > a, -.toolbar-subscribe-button > a, -.toolbar-controls .toolbar-subscribe-button > a, -.options-menu li > a, -.options-submenu li > a, -.plusminus .task-checker > button, -.task-options .priority-multiplier li > button, -.task-options .task-attributes li > button, -.task-options .repeat-days li > button, -.task-options .repeat-frequency li > button, -.task-options .save-close > button, -.task-checklist-edit > ul li > button, -.filters .filters-tags > button, -.chat-buttons > button, -.toolbar-mobile > button, -.toolbar-nav .toolbar-button > button, -.toolbar-sync > button, -.toolbar-settings > button, -.toolbar-audio > button, -.toolbar-notifs > button, -.toolbar-controls .toolbar-controls-button > button, -.toolbar-subscribe-button > button, -.toolbar-controls .toolbar-subscribe-button > button, -.options-menu li > button, -.options-submenu li > button, -.plusminus .task-checker > input, -.task-options .priority-multiplier li > input, -.task-options .task-attributes li > input, -.task-options .repeat-days li > input, -.task-options .repeat-frequency li > input, -.task-options .save-close > input, -.task-checklist-edit > ul li > input, -.filters .filters-tags > input, -.chat-buttons > input, -.toolbar-mobile > input, -.toolbar-nav .toolbar-button > input, -.toolbar-sync > input, -.toolbar-settings > input, -.toolbar-audio > input, -.toolbar-notifs > input, -.toolbar-controls .toolbar-controls-button > input, -.toolbar-subscribe-button > input, -.toolbar-controls .toolbar-subscribe-button > input, -.options-menu li > input, -.options-submenu li > input, -.plusminus .task-checker label::after, -.task-options .priority-multiplier li label::after, -.task-options .task-attributes li label::after, -.task-options .repeat-days li label::after, -.task-options .repeat-frequency li label::after, -.task-options .save-close label::after, -.task-checklist-edit > ul li label::after, -.filters .filters-tags label::after, -.chat-buttons label::after, -.toolbar-mobile label::after, -.toolbar-nav .toolbar-button label::after, -.toolbar-sync label::after, -.toolbar-settings label::after, -.toolbar-audio label::after, -.toolbar-notifs label::after, -.toolbar-controls .toolbar-controls-button label::after, -.toolbar-subscribe-button label::after, -.toolbar-controls .toolbar-subscribe-button label::after, -.options-menu li label::after, -.options-submenu li label::after { - border: 1px solid #ccc !important; - -webkit-border-radius: 0.382em !important; - border-radius: 0.382em !important; -} -.plusminus .task-checker .highlight, -.task-options .priority-multiplier li .highlight, -.task-options .task-attributes li .highlight, -.task-options .repeat-days li .highlight, -.task-options .repeat-frequency li .highlight, -.task-options .save-close .highlight, -.task-checklist-edit > ul li .highlight, -.filters .filters-tags .highlight, -.chat-buttons .highlight, -.toolbar-mobile .highlight, -.toolbar-nav .toolbar-button .highlight, -.toolbar-sync .highlight, -.toolbar-settings .highlight, -.toolbar-audio .highlight, -.toolbar-notifs .highlight, -.toolbar-controls .toolbar-controls-button .highlight, -.toolbar-subscribe-button .highlight, -.toolbar-controls .toolbar-subscribe-button .highlight, -.options-menu li .highlight, -.options-submenu li .highlight { - border-color: #89aef0 !important; - background-color: #c9daf8 !important; - background-color: #c9daf8 !important; -} -.plusminus .task-checker .highlight, -.task-options .priority-multiplier li .highlight, -.task-options .task-attributes li .highlight, -.task-options .repeat-days li .highlight, -.task-options .repeat-frequency li .highlight, -.task-options .save-close .highlight, -.task-checklist-edit > ul li .highlight, -.filters .filters-tags .highlight, -.chat-buttons .highlight, -.toolbar-mobile .highlight, -.toolbar-nav .toolbar-button .highlight, -.toolbar-sync .highlight, -.toolbar-settings .highlight, -.toolbar-audio .highlight, -.toolbar-notifs .highlight, -.toolbar-controls .toolbar-controls-button .highlight, -.toolbar-subscribe-button .highlight, -.toolbar-controls .toolbar-subscribe-button .highlight, -.options-menu li .highlight, -.options-submenu li .highlight { - color: #3e5555 !important; -} -.plusminus .task-checker .highlight:active, -.task-options .priority-multiplier li .highlight:active, -.task-options .task-attributes li .highlight:active, -.task-options .repeat-days li .highlight:active, -.task-options .repeat-frequency li .highlight:active, -.task-options .save-close .highlight:active, -.task-checklist-edit > ul li .highlight:active, -.filters .filters-tags .highlight:active, -.chat-buttons .highlight:active, -.toolbar-mobile .highlight:active, -.toolbar-nav .toolbar-button .highlight:active, -.toolbar-sync .highlight:active, -.toolbar-settings .highlight:active, -.toolbar-audio .highlight:active, -.toolbar-notifs .highlight:active, -.toolbar-controls .toolbar-controls-button .highlight:active, -.toolbar-subscribe-button .highlight:active, -.toolbar-controls .toolbar-subscribe-button .highlight:active, -.options-menu li .highlight:active, -.options-submenu li .highlight:active { - background-color: #14459a !important; -} -@media screen and (min-width: 768px) { - .plusminus .task-checker .highlight:hover, - .task-options .priority-multiplier li .highlight:hover, - .task-options .task-attributes li .highlight:hover, - .task-options .repeat-days li .highlight:hover, - .task-options .repeat-frequency li .highlight:hover, - .task-options .save-close .highlight:hover, - .task-checklist-edit > ul li .highlight:hover, - .filters .filters-tags .highlight:hover, - .chat-buttons .highlight:hover, - .toolbar-mobile .highlight:hover, - .toolbar-nav .toolbar-button .highlight:hover, - .toolbar-sync .highlight:hover, - .toolbar-settings .highlight:hover, - .toolbar-audio .highlight:hover, - .toolbar-notifs .highlight:hover, - .toolbar-controls .toolbar-controls-button .highlight:hover, - .toolbar-subscribe-button .highlight:hover, - .toolbar-controls .toolbar-subscribe-button .highlight:hover, - .options-menu li .highlight:hover, - .options-submenu li .highlight:hover { - background-color: #c0d4f7 !important; - } -} -.plusminus .task-checker .highlight > a, -.task-options .priority-multiplier li .highlight > a, -.task-options .task-attributes li .highlight > a, -.task-options .repeat-days li .highlight > a, -.task-options .repeat-frequency li .highlight > a, -.task-options .save-close .highlight > a, -.task-checklist-edit > ul li .highlight > a, -.filters .filters-tags .highlight > a, -.chat-buttons .highlight > a, -.toolbar-mobile .highlight > a, -.toolbar-nav .toolbar-button .highlight > a, -.toolbar-sync .highlight > a, -.toolbar-settings .highlight > a, -.toolbar-audio .highlight > a, -.toolbar-notifs .highlight > a, -.toolbar-controls .toolbar-controls-button .highlight > a, -.toolbar-subscribe-button .highlight > a, -.toolbar-controls .toolbar-subscribe-button .highlight > a, -.options-menu li .highlight > a, -.options-submenu li .highlight > a, -.plusminus .task-checker .highlight > button, -.task-options .priority-multiplier li .highlight > button, -.task-options .task-attributes li .highlight > button, -.task-options .repeat-days li .highlight > button, -.task-options .repeat-frequency li .highlight > button, -.task-options .save-close .highlight > button, -.task-checklist-edit > ul li .highlight > button, -.filters .filters-tags .highlight > button, -.chat-buttons .highlight > button, -.toolbar-mobile .highlight > button, -.toolbar-nav .toolbar-button .highlight > button, -.toolbar-sync .highlight > button, -.toolbar-settings .highlight > button, -.toolbar-audio .highlight > button, -.toolbar-notifs .highlight > button, -.toolbar-controls .toolbar-controls-button .highlight > button, -.toolbar-subscribe-button .highlight > button, -.toolbar-controls .toolbar-subscribe-button .highlight > button, -.options-menu li .highlight > button, -.options-submenu li .highlight > button { - background-color: #c9daf8 !important; -} -.plusminus .task-checker .highlight > a:active, -.task-options .priority-multiplier li .highlight > a:active, -.task-options .task-attributes li .highlight > a:active, -.task-options .repeat-days li .highlight > a:active, -.task-options .repeat-frequency li .highlight > a:active, -.task-options .save-close .highlight > a:active, -.task-checklist-edit > ul li .highlight > a:active, -.filters .filters-tags .highlight > a:active, -.chat-buttons .highlight > a:active, -.toolbar-mobile .highlight > a:active, -.toolbar-nav .toolbar-button .highlight > a:active, -.toolbar-sync .highlight > a:active, -.toolbar-settings .highlight > a:active, -.toolbar-audio .highlight > a:active, -.toolbar-notifs .highlight > a:active, -.toolbar-controls .toolbar-controls-button .highlight > a:active, -.toolbar-subscribe-button .highlight > a:active, -.toolbar-controls .toolbar-subscribe-button .highlight > a:active, -.options-menu li .highlight > a:active, -.options-submenu li .highlight > a:active, -.plusminus .task-checker .highlight > button:active, -.task-options .priority-multiplier li .highlight > button:active, -.task-options .task-attributes li .highlight > button:active, -.task-options .repeat-days li .highlight > button:active, -.task-options .repeat-frequency li .highlight > button:active, -.task-options .save-close .highlight > button:active, -.task-checklist-edit > ul li .highlight > button:active, -.filters .filters-tags .highlight > button:active, -.chat-buttons .highlight > button:active, -.toolbar-mobile .highlight > button:active, -.toolbar-nav .toolbar-button .highlight > button:active, -.toolbar-sync .highlight > button:active, -.toolbar-settings .highlight > button:active, -.toolbar-audio .highlight > button:active, -.toolbar-notifs .highlight > button:active, -.toolbar-controls .toolbar-controls-button .highlight > button:active, -.toolbar-subscribe-button .highlight > button:active, -.toolbar-controls .toolbar-subscribe-button .highlight > button:active, -.options-menu li .highlight > button:active, -.options-submenu li .highlight > button:active { - background-color: #14459a !important; -} -@media screen and (min-width: 768px) { - .plusminus .task-checker .highlight > a:hover, - .task-options .priority-multiplier li .highlight > a:hover, - .task-options .task-attributes li .highlight > a:hover, - .task-options .repeat-days li .highlight > a:hover, - .task-options .repeat-frequency li .highlight > a:hover, - .task-options .save-close .highlight > a:hover, - .task-checklist-edit > ul li .highlight > a:hover, - .filters .filters-tags .highlight > a:hover, - .chat-buttons .highlight > a:hover, - .toolbar-mobile .highlight > a:hover, - .toolbar-nav .toolbar-button .highlight > a:hover, - .toolbar-sync .highlight > a:hover, - .toolbar-settings .highlight > a:hover, - .toolbar-audio .highlight > a:hover, - .toolbar-notifs .highlight > a:hover, - .toolbar-controls .toolbar-controls-button .highlight > a:hover, - .toolbar-subscribe-button .highlight > a:hover, - .toolbar-controls .toolbar-subscribe-button .highlight > a:hover, - .options-menu li .highlight > a:hover, - .options-submenu li .highlight > a:hover, - .plusminus .task-checker .highlight > button:hover, - .task-options .priority-multiplier li .highlight > button:hover, - .task-options .task-attributes li .highlight > button:hover, - .task-options .repeat-days li .highlight > button:hover, - .task-options .repeat-frequency li .highlight > button:hover, - .task-options .save-close .highlight > button:hover, - .task-checklist-edit > ul li .highlight > button:hover, - .filters .filters-tags .highlight > button:hover, - .chat-buttons .highlight > button:hover, - .toolbar-mobile .highlight > button:hover, - .toolbar-nav .toolbar-button .highlight > button:hover, - .toolbar-sync .highlight > button:hover, - .toolbar-settings .highlight > button:hover, - .toolbar-audio .highlight > button:hover, - .toolbar-notifs .highlight > button:hover, - .toolbar-controls .toolbar-controls-button .highlight > button:hover, - .toolbar-subscribe-button .highlight > button:hover, - .toolbar-controls .toolbar-subscribe-button .highlight > button:hover, - .options-menu li .highlight > button:hover, - .options-submenu li .highlight > button:hover { - background-color: #c0d4f7 !important; - } -} -.plusminus .task-checker .highlight > a, -.task-options .priority-multiplier li .highlight > a, -.task-options .task-attributes li .highlight > a, -.task-options .repeat-days li .highlight > a, -.task-options .repeat-frequency li .highlight > a, -.task-options .save-close .highlight > a, -.task-checklist-edit > ul li .highlight > a, -.filters .filters-tags .highlight > a, -.chat-buttons .highlight > a, -.toolbar-mobile .highlight > a, -.toolbar-nav .toolbar-button .highlight > a, -.toolbar-sync .highlight > a, -.toolbar-settings .highlight > a, -.toolbar-audio .highlight > a, -.toolbar-notifs .highlight > a, -.toolbar-controls .toolbar-controls-button .highlight > a, -.toolbar-subscribe-button .highlight > a, -.toolbar-controls .toolbar-subscribe-button .highlight > a, -.options-menu li .highlight > a, -.options-submenu li .highlight > a, -.plusminus .task-checker .highlight > button, -.task-options .priority-multiplier li .highlight > button, -.task-options .task-attributes li .highlight > button, -.task-options .repeat-days li .highlight > button, -.task-options .repeat-frequency li .highlight > button, -.task-options .save-close .highlight > button, -.task-checklist-edit > ul li .highlight > button, -.filters .filters-tags .highlight > button, -.chat-buttons .highlight > button, -.toolbar-mobile .highlight > button, -.toolbar-nav .toolbar-button .highlight > button, -.toolbar-sync .highlight > button, -.toolbar-settings .highlight > button, -.toolbar-audio .highlight > button, -.toolbar-notifs .highlight > button, -.toolbar-controls .toolbar-controls-button .highlight > button, -.toolbar-subscribe-button .highlight > button, -.toolbar-controls .toolbar-subscribe-button .highlight > button, -.options-menu li .highlight > button, -.options-submenu li .highlight > button, -.plusminus .task-checker .highlight > input, -.task-options .priority-multiplier li .highlight > input, -.task-options .task-attributes li .highlight > input, -.task-options .repeat-days li .highlight > input, -.task-options .repeat-frequency li .highlight > input, -.task-options .save-close .highlight > input, -.task-checklist-edit > ul li .highlight > input, -.filters .filters-tags .highlight > input, -.chat-buttons .highlight > input, -.toolbar-mobile .highlight > input, -.toolbar-nav .toolbar-button .highlight > input, -.toolbar-sync .highlight > input, -.toolbar-settings .highlight > input, -.toolbar-audio .highlight > input, -.toolbar-notifs .highlight > input, -.toolbar-controls .toolbar-controls-button .highlight > input, -.toolbar-subscribe-button .highlight > input, -.toolbar-controls .toolbar-subscribe-button .highlight > input, -.options-menu li .highlight > input, -.options-submenu li .highlight > input, -.plusminus .task-checker .highlight textarea, -.task-options .priority-multiplier li .highlight textarea, -.task-options .task-attributes li .highlight textarea, -.task-options .repeat-days li .highlight textarea, -.task-options .repeat-frequency li .highlight textarea, -.task-options .save-close .highlight textarea, -.task-checklist-edit > ul li .highlight textarea, -.filters .filters-tags .highlight textarea, -.chat-buttons .highlight textarea, -.toolbar-mobile .highlight textarea, -.toolbar-nav .toolbar-button .highlight textarea, -.toolbar-sync .highlight textarea, -.toolbar-settings .highlight textarea, -.toolbar-audio .highlight textarea, -.toolbar-notifs .highlight textarea, -.toolbar-controls .toolbar-controls-button .highlight textarea, -.toolbar-subscribe-button .highlight textarea, -.toolbar-controls .toolbar-subscribe-button .highlight textarea, -.options-menu li .highlight textarea, -.options-submenu li .highlight textarea { - color: #3e5555 !important; - border-color: #c6d6d6 !important; -} -.plusminus .task-checker .highlight > input:hover, -.task-options .priority-multiplier li .highlight > input:hover, -.task-options .task-attributes li .highlight > input:hover, -.task-options .repeat-days li .highlight > input:hover, -.task-options .repeat-frequency li .highlight > input:hover, -.task-options .save-close .highlight > input:hover, -.task-checklist-edit > ul li .highlight > input:hover, -.filters .filters-tags .highlight > input:hover, -.chat-buttons .highlight > input:hover, -.toolbar-mobile .highlight > input:hover, -.toolbar-nav .toolbar-button .highlight > input:hover, -.toolbar-sync .highlight > input:hover, -.toolbar-settings .highlight > input:hover, -.toolbar-audio .highlight > input:hover, -.toolbar-notifs .highlight > input:hover, -.toolbar-controls .toolbar-controls-button .highlight > input:hover, -.toolbar-subscribe-button .highlight > input:hover, -.toolbar-controls .toolbar-subscribe-button .highlight > input:hover, -.options-menu li .highlight > input:hover, -.options-submenu li .highlight > input:hover, -.plusminus .task-checker .highlight textarea:hover, -.task-options .priority-multiplier li .highlight textarea:hover, -.task-options .task-attributes li .highlight textarea:hover, -.task-options .repeat-days li .highlight textarea:hover, -.task-options .repeat-frequency li .highlight textarea:hover, -.task-options .save-close .highlight textarea:hover, -.task-checklist-edit > ul li .highlight textarea:hover, -.filters .filters-tags .highlight textarea:hover, -.chat-buttons .highlight textarea:hover, -.toolbar-mobile .highlight textarea:hover, -.toolbar-nav .toolbar-button .highlight textarea:hover, -.toolbar-sync .highlight textarea:hover, -.toolbar-settings .highlight textarea:hover, -.toolbar-audio .highlight textarea:hover, -.toolbar-notifs .highlight textarea:hover, -.toolbar-controls .toolbar-controls-button .highlight textarea:hover, -.toolbar-subscribe-button .highlight textarea:hover, -.toolbar-controls .toolbar-subscribe-button .highlight textarea:hover, -.options-menu li .highlight textarea:hover, -.options-submenu li .highlight textarea:hover { - border-color: #97b3b3 !important; -} -.plusminus .task-checker .highlight > input:focus, -.task-options .priority-multiplier li .highlight > input:focus, -.task-options .task-attributes li .highlight > input:focus, -.task-options .repeat-days li .highlight > input:focus, -.task-options .repeat-frequency li .highlight > input:focus, -.task-options .save-close .highlight > input:focus, -.task-checklist-edit > ul li .highlight > input:focus, -.filters .filters-tags .highlight > input:focus, -.chat-buttons .highlight > input:focus, -.toolbar-mobile .highlight > input:focus, -.toolbar-nav .toolbar-button .highlight > input:focus, -.toolbar-sync .highlight > input:focus, -.toolbar-settings .highlight > input:focus, -.toolbar-audio .highlight > input:focus, -.toolbar-notifs .highlight > input:focus, -.toolbar-controls .toolbar-controls-button .highlight > input:focus, -.toolbar-subscribe-button .highlight > input:focus, -.toolbar-controls .toolbar-subscribe-button .highlight > input:focus, -.options-menu li .highlight > input:focus, -.options-submenu li .highlight > input:focus, -.plusminus .task-checker .highlight textarea:focus, -.task-options .priority-multiplier li .highlight textarea:focus, -.task-options .task-attributes li .highlight textarea:focus, -.task-options .repeat-days li .highlight textarea:focus, -.task-options .repeat-frequency li .highlight textarea:focus, -.task-options .save-close .highlight textarea:focus, -.task-checklist-edit > ul li .highlight textarea:focus, -.filters .filters-tags .highlight textarea:focus, -.chat-buttons .highlight textarea:focus, -.toolbar-mobile .highlight textarea:focus, -.toolbar-nav .toolbar-button .highlight textarea:focus, -.toolbar-sync .highlight textarea:focus, -.toolbar-settings .highlight textarea:focus, -.toolbar-audio .highlight textarea:focus, -.toolbar-notifs .highlight textarea:focus, -.toolbar-controls .toolbar-controls-button .highlight textarea:focus, -.toolbar-subscribe-button .highlight textarea:focus, -.toolbar-controls .toolbar-subscribe-button .highlight textarea:focus, -.options-menu li .highlight textarea:focus, -.options-submenu li .highlight textarea:focus { - border-color: #4f6d6d !important; - outline: none; -} -.plusminus .task-checker .highlight > input + button:focus, -.task-options .priority-multiplier li .highlight > input + button:focus, -.task-options .task-attributes li .highlight > input + button:focus, -.task-options .repeat-days li .highlight > input + button:focus, -.task-options .repeat-frequency li .highlight > input + button:focus, -.task-options .save-close .highlight > input + button:focus, -.task-checklist-edit > ul li .highlight > input + button:focus, -.filters .filters-tags .highlight > input + button:focus, -.chat-buttons .highlight > input + button:focus, -.toolbar-mobile .highlight > input + button:focus, -.toolbar-nav .toolbar-button .highlight > input + button:focus, -.toolbar-sync .highlight > input + button:focus, -.toolbar-settings .highlight > input + button:focus, -.toolbar-audio .highlight > input + button:focus, -.toolbar-notifs .highlight > input + button:focus, -.toolbar-controls .toolbar-controls-button .highlight > input + button:focus, -.toolbar-subscribe-button .highlight > input + button:focus, -.toolbar-controls .toolbar-subscribe-button .highlight > input + button:focus, -.options-menu li .highlight > input + button:focus, -.options-submenu li .highlight > input + button:focus, -.plusminus .task-checker .highlight textarea + button:focus, -.task-options .priority-multiplier li .highlight textarea + button:focus, -.task-options .task-attributes li .highlight textarea + button:focus, -.task-options .repeat-days li .highlight textarea + button:focus, -.task-options .repeat-frequency li .highlight textarea + button:focus, -.task-options .save-close .highlight textarea + button:focus, -.task-checklist-edit > ul li .highlight textarea + button:focus, -.filters .filters-tags .highlight textarea + button:focus, -.chat-buttons .highlight textarea + button:focus, -.toolbar-mobile .highlight textarea + button:focus, -.toolbar-nav .toolbar-button .highlight textarea + button:focus, -.toolbar-sync .highlight textarea + button:focus, -.toolbar-settings .highlight textarea + button:focus, -.toolbar-audio .highlight textarea + button:focus, -.toolbar-notifs .highlight textarea + button:focus, -.toolbar-controls .toolbar-controls-button .highlight textarea + button:focus, -.toolbar-subscribe-button .highlight textarea + button:focus, -.toolbar-controls .toolbar-subscribe-button .highlight textarea + button:focus, -.options-menu li .highlight textarea + button:focus, -.options-submenu li .highlight textarea + button:focus { - border-color: #97b3b3 !important; - background-color: #e2eaea !important; - outline: none; -} -.plusminus .task-checker .highlight > input + button:active, -.task-options .priority-multiplier li .highlight > input + button:active, -.task-options .task-attributes li .highlight > input + button:active, -.task-options .repeat-days li .highlight > input + button:active, -.task-options .repeat-frequency li .highlight > input + button:active, -.task-options .save-close .highlight > input + button:active, -.task-checklist-edit > ul li .highlight > input + button:active, -.filters .filters-tags .highlight > input + button:active, -.chat-buttons .highlight > input + button:active, -.toolbar-mobile .highlight > input + button:active, -.toolbar-nav .toolbar-button .highlight > input + button:active, -.toolbar-sync .highlight > input + button:active, -.toolbar-settings .highlight > input + button:active, -.toolbar-audio .highlight > input + button:active, -.toolbar-notifs .highlight > input + button:active, -.toolbar-controls .toolbar-controls-button .highlight > input + button:active, -.toolbar-subscribe-button .highlight > input + button:active, -.toolbar-controls .toolbar-subscribe-button .highlight > input + button:active, -.options-menu li .highlight > input + button:active, -.options-submenu li .highlight > input + button:active, -.plusminus .task-checker .highlight textarea + button:active, -.task-options .priority-multiplier li .highlight textarea + button:active, -.task-options .task-attributes li .highlight textarea + button:active, -.task-options .repeat-days li .highlight textarea + button:active, -.task-options .repeat-frequency li .highlight textarea + button:active, -.task-options .save-close .highlight textarea + button:active, -.task-checklist-edit > ul li .highlight textarea + button:active, -.filters .filters-tags .highlight textarea + button:active, -.chat-buttons .highlight textarea + button:active, -.toolbar-mobile .highlight textarea + button:active, -.toolbar-nav .toolbar-button .highlight textarea + button:active, -.toolbar-sync .highlight textarea + button:active, -.toolbar-settings .highlight textarea + button:active, -.toolbar-audio .highlight textarea + button:active, -.toolbar-notifs .highlight textarea + button:active, -.toolbar-controls .toolbar-controls-button .highlight textarea + button:active, -.toolbar-subscribe-button .highlight textarea + button:active, -.toolbar-controls .toolbar-subscribe-button .highlight textarea + button:active, -.options-menu li .highlight textarea + button:active, -.options-submenu li .highlight textarea + button:active { - background-color: #c6d6d6 !important; -} -.plusminus .task-checker .highlight > a:nth-of-type(2), -.task-options .priority-multiplier li .highlight > a:nth-of-type(2), -.task-options .task-attributes li .highlight > a:nth-of-type(2), -.task-options .repeat-days li .highlight > a:nth-of-type(2), -.task-options .repeat-frequency li .highlight > a:nth-of-type(2), -.task-options .save-close .highlight > a:nth-of-type(2), -.task-checklist-edit > ul li .highlight > a:nth-of-type(2), -.filters .filters-tags .highlight > a:nth-of-type(2), -.chat-buttons .highlight > a:nth-of-type(2), -.toolbar-mobile .highlight > a:nth-of-type(2), -.toolbar-nav .toolbar-button .highlight > a:nth-of-type(2), -.toolbar-sync .highlight > a:nth-of-type(2), -.toolbar-settings .highlight > a:nth-of-type(2), -.toolbar-audio .highlight > a:nth-of-type(2), -.toolbar-notifs .highlight > a:nth-of-type(2), -.toolbar-controls .toolbar-controls-button .highlight > a:nth-of-type(2), -.toolbar-subscribe-button .highlight > a:nth-of-type(2), -.toolbar-controls .toolbar-subscribe-button .highlight > a:nth-of-type(2), -.options-menu li .highlight > a:nth-of-type(2), -.options-submenu li .highlight > a:nth-of-type(2) { - border-left: 1px solid #bad0f6 !important; -} -@media screen and (min-width: 768px) { - .plusminus .task-checker .highlight > div, - .task-options .priority-multiplier li .highlight > div, - .task-options .task-attributes li .highlight > div, - .task-options .repeat-days li .highlight > div, - .task-options .repeat-frequency li .highlight > div, - .task-options .save-close .highlight > div, - .task-checklist-edit > ul li .highlight > div, - .filters .filters-tags .highlight > div, - .chat-buttons .highlight > div, - .toolbar-mobile .highlight > div, - .toolbar-nav .toolbar-button .highlight > div, - .toolbar-sync .highlight > div, - .toolbar-settings .highlight > div, - .toolbar-audio .highlight > div, - .toolbar-notifs .highlight > div, - .toolbar-controls .toolbar-controls-button .highlight > div, - .toolbar-subscribe-button .highlight > div, - .toolbar-controls .toolbar-subscribe-button .highlight > div, - .options-menu li .highlight > div, - .options-submenu li .highlight > div { - border: 1px solid #c6d6d6; - } -} -.plusminus .task-checker .highlight > div h4, -.task-options .priority-multiplier li .highlight > div h4, -.task-options .task-attributes li .highlight > div h4, -.task-options .repeat-days li .highlight > div h4, -.task-options .repeat-frequency li .highlight > div h4, -.task-options .save-close .highlight > div h4, -.task-checklist-edit > ul li .highlight > div h4, -.filters .filters-tags .highlight > div h4, -.chat-buttons .highlight > div h4, -.toolbar-mobile .highlight > div h4, -.toolbar-nav .toolbar-button .highlight > div h4, -.toolbar-sync .highlight > div h4, -.toolbar-settings .highlight > div h4, -.toolbar-audio .highlight > div h4, -.toolbar-notifs .highlight > div h4, -.toolbar-controls .toolbar-controls-button .highlight > div h4, -.toolbar-subscribe-button .highlight > div h4, -.toolbar-controls .toolbar-subscribe-button .highlight > div h4, -.options-menu li .highlight > div h4, -.options-submenu li .highlight > div h4 { - color: #4f6d6d; - background-color: #f4f7f7; - border-color: #c6d6d6; -} -.plusminus .task-checker .highlight > div > ul:first-child:before, -.task-options .priority-multiplier li .highlight > div > ul:first-child:before, -.task-options .task-attributes li .highlight > div > ul:first-child:before, -.task-options .repeat-days li .highlight > div > ul:first-child:before, -.task-options .repeat-frequency li .highlight > div > ul:first-child:before, -.task-options .save-close .highlight > div > ul:first-child:before, -.task-checklist-edit > ul li .highlight > div > ul:first-child:before, -.filters .filters-tags .highlight > div > ul:first-child:before, -.chat-buttons .highlight > div > ul:first-child:before, -.toolbar-mobile .highlight > div > ul:first-child:before, -.toolbar-nav .toolbar-button .highlight > div > ul:first-child:before, -.toolbar-sync .highlight > div > ul:first-child:before, -.toolbar-settings .highlight > div > ul:first-child:before, -.toolbar-audio .highlight > div > ul:first-child:before, -.toolbar-notifs .highlight > div > ul:first-child:before, -.toolbar-controls .toolbar-controls-button .highlight > div > ul:first-child:before, -.toolbar-subscribe-button .highlight > div > ul:first-child:before, -.toolbar-controls .toolbar-subscribe-button .highlight > div > ul:first-child:before, -.options-menu li .highlight > div > ul:first-child:before, -.options-submenu li .highlight > div > ul:first-child:before, -.plusminus .task-checker .highlight > div > div:first-child:before, -.task-options .priority-multiplier li .highlight > div > div:first-child:before, -.task-options .task-attributes li .highlight > div > div:first-child:before, -.task-options .repeat-days li .highlight > div > div:first-child:before, -.task-options .repeat-frequency li .highlight > div > div:first-child:before, -.task-options .save-close .highlight > div > div:first-child:before, -.task-checklist-edit > ul li .highlight > div > div:first-child:before, -.filters .filters-tags .highlight > div > div:first-child:before, -.chat-buttons .highlight > div > div:first-child:before, -.toolbar-mobile .highlight > div > div:first-child:before, -.toolbar-nav .toolbar-button .highlight > div > div:first-child:before, -.toolbar-sync .highlight > div > div:first-child:before, -.toolbar-settings .highlight > div > div:first-child:before, -.toolbar-audio .highlight > div > div:first-child:before, -.toolbar-notifs .highlight > div > div:first-child:before, -.toolbar-controls .toolbar-controls-button .highlight > div > div:first-child:before, -.toolbar-subscribe-button .highlight > div > div:first-child:before, -.toolbar-controls .toolbar-subscribe-button .highlight > div > div:first-child:before, -.options-menu li .highlight > div > div:first-child:before, -.options-submenu li .highlight > div > div:first-child:before { - background-color: #fff; - border-color: #c6d6d6; -} -.plusminus .task-checker .highlight > div h4:before, -.task-options .priority-multiplier li .highlight > div h4:before, -.task-options .task-attributes li .highlight > div h4:before, -.task-options .repeat-days li .highlight > div h4:before, -.task-options .repeat-frequency li .highlight > div h4:before, -.task-options .save-close .highlight > div h4:before, -.task-checklist-edit > ul li .highlight > div h4:before, -.filters .filters-tags .highlight > div h4:before, -.chat-buttons .highlight > div h4:before, -.toolbar-mobile .highlight > div h4:before, -.toolbar-nav .toolbar-button .highlight > div h4:before, -.toolbar-sync .highlight > div h4:before, -.toolbar-settings .highlight > div h4:before, -.toolbar-audio .highlight > div h4:before, -.toolbar-notifs .highlight > div h4:before, -.toolbar-controls .toolbar-controls-button .highlight > div h4:before, -.toolbar-subscribe-button .highlight > div h4:before, -.toolbar-controls .toolbar-subscribe-button .highlight > div h4:before, -.options-menu li .highlight > div h4:before, -.options-submenu li .highlight > div h4:before { - background-color: #f4f7f7; - border-color: #c6d6d6; -} -.plusminus .task-checker .highlight > div ul, -.task-options .priority-multiplier li .highlight > div ul, -.task-options .task-attributes li .highlight > div ul, -.task-options .repeat-days li .highlight > div ul, -.task-options .repeat-frequency li .highlight > div ul, -.task-options .save-close .highlight > div ul, -.task-checklist-edit > ul li .highlight > div ul, -.filters .filters-tags .highlight > div ul, -.chat-buttons .highlight > div ul, -.toolbar-mobile .highlight > div ul, -.toolbar-nav .toolbar-button .highlight > div ul, -.toolbar-sync .highlight > div ul, -.toolbar-settings .highlight > div ul, -.toolbar-audio .highlight > div ul, -.toolbar-notifs .highlight > div ul, -.toolbar-controls .toolbar-controls-button .highlight > div ul, -.toolbar-subscribe-button .highlight > div ul, -.toolbar-controls .toolbar-subscribe-button .highlight > div ul, -.options-menu li .highlight > div ul, -.options-submenu li .highlight > div ul { - border-color: #f4f7f7; -} -.plusminus .task-checker .highlight > div a, -.task-options .priority-multiplier li .highlight > div a, -.task-options .task-attributes li .highlight > div a, -.task-options .repeat-days li .highlight > div a, -.task-options .repeat-frequency li .highlight > div a, -.task-options .save-close .highlight > div a, -.task-checklist-edit > ul li .highlight > div a, -.filters .filters-tags .highlight > div a, -.chat-buttons .highlight > div a, -.toolbar-mobile .highlight > div a, -.toolbar-nav .toolbar-button .highlight > div a, -.toolbar-sync .highlight > div a, -.toolbar-settings .highlight > div a, -.toolbar-audio .highlight > div a, -.toolbar-notifs .highlight > div a, -.toolbar-controls .toolbar-controls-button .highlight > div a, -.toolbar-subscribe-button .highlight > div a, -.toolbar-controls .toolbar-subscribe-button .highlight > div a, -.options-menu li .highlight > div a, -.options-submenu li .highlight > div a, -.plusminus .task-checker .highlight > div a:link, -.task-options .priority-multiplier li .highlight > div a:link, -.task-options .task-attributes li .highlight > div a:link, -.task-options .repeat-days li .highlight > div a:link, -.task-options .repeat-frequency li .highlight > div a:link, -.task-options .save-close .highlight > div a:link, -.task-checklist-edit > ul li .highlight > div a:link, -.filters .filters-tags .highlight > div a:link, -.chat-buttons .highlight > div a:link, -.toolbar-mobile .highlight > div a:link, -.toolbar-nav .toolbar-button .highlight > div a:link, -.toolbar-sync .highlight > div a:link, -.toolbar-settings .highlight > div a:link, -.toolbar-audio .highlight > div a:link, -.toolbar-notifs .highlight > div a:link, -.toolbar-controls .toolbar-controls-button .highlight > div a:link, -.toolbar-subscribe-button .highlight > div a:link, -.toolbar-controls .toolbar-subscribe-button .highlight > div a:link, -.options-menu li .highlight > div a:link, -.options-submenu li .highlight > div a:link, -.plusminus .task-checker .highlight > div a:visited, -.task-options .priority-multiplier li .highlight > div a:visited, -.task-options .task-attributes li .highlight > div a:visited, -.task-options .repeat-days li .highlight > div a:visited, -.task-options .repeat-frequency li .highlight > div a:visited, -.task-options .save-close .highlight > div a:visited, -.task-checklist-edit > ul li .highlight > div a:visited, -.filters .filters-tags .highlight > div a:visited, -.chat-buttons .highlight > div a:visited, -.toolbar-mobile .highlight > div a:visited, -.toolbar-nav .toolbar-button .highlight > div a:visited, -.toolbar-sync .highlight > div a:visited, -.toolbar-settings .highlight > div a:visited, -.toolbar-audio .highlight > div a:visited, -.toolbar-notifs .highlight > div a:visited, -.toolbar-controls .toolbar-controls-button .highlight > div a:visited, -.toolbar-subscribe-button .highlight > div a:visited, -.toolbar-controls .toolbar-subscribe-button .highlight > div a:visited, -.options-menu li .highlight > div a:visited, -.options-submenu li .highlight > div a:visited { - color: #3e5555 !important; -} -.plusminus .task-checker .highlight .glyphicon, -.task-options .priority-multiplier li .highlight .glyphicon, -.task-options .task-attributes li .highlight .glyphicon, -.task-options .repeat-days li .highlight .glyphicon, -.task-options .repeat-frequency li .highlight .glyphicon, -.task-options .save-close .highlight .glyphicon, -.task-checklist-edit > ul li .highlight .glyphicon, -.filters .filters-tags .highlight .glyphicon, -.chat-buttons .highlight .glyphicon, -.toolbar-mobile .highlight .glyphicon, -.toolbar-nav .toolbar-button .highlight .glyphicon, -.toolbar-sync .highlight .glyphicon, -.toolbar-settings .highlight .glyphicon, -.toolbar-audio .highlight .glyphicon, -.toolbar-notifs .highlight .glyphicon, -.toolbar-controls .toolbar-controls-button .highlight .glyphicon, -.toolbar-subscribe-button .highlight .glyphicon, -.toolbar-controls .toolbar-subscribe-button .highlight .glyphicon, -.options-menu li .highlight .glyphicon, -.options-submenu li .highlight .glyphicon { - color: #537272; -} -.plusminus .task-checker .highlight .tooltip, -.task-options .priority-multiplier li .highlight .tooltip, -.task-options .task-attributes li .highlight .tooltip, -.task-options .repeat-days li .highlight .tooltip, -.task-options .repeat-frequency li .highlight .tooltip, -.task-options .save-close .highlight .tooltip, -.task-checklist-edit > ul li .highlight .tooltip, -.filters .filters-tags .highlight .tooltip, -.chat-buttons .highlight .tooltip, -.toolbar-mobile .highlight .tooltip, -.toolbar-nav .toolbar-button .highlight .tooltip, -.toolbar-sync .highlight .tooltip, -.toolbar-settings .highlight .tooltip, -.toolbar-audio .highlight .tooltip, -.toolbar-notifs .highlight .tooltip, -.toolbar-controls .toolbar-controls-button .highlight .tooltip, -.toolbar-subscribe-button .highlight .tooltip, -.toolbar-controls .toolbar-subscribe-button .highlight .tooltip, -.options-menu li .highlight .tooltip, -.options-submenu li .highlight .tooltip { - border: 0; -} -.plusminus .task-checker .highlight.active a, -.task-options .priority-multiplier li .highlight.active a, -.task-options .task-attributes li .highlight.active a, -.task-options .repeat-days li .highlight.active a, -.task-options .repeat-frequency li .highlight.active a, -.task-options .save-close .highlight.active a, -.task-checklist-edit > ul li .highlight.active a, -.filters .filters-tags .highlight.active a, -.chat-buttons .highlight.active a, -.toolbar-mobile .highlight.active a, -.toolbar-nav .toolbar-button .highlight.active a, -.toolbar-sync .highlight.active a, -.toolbar-settings .highlight.active a, -.toolbar-audio .highlight.active a, -.toolbar-notifs .highlight.active a, -.toolbar-controls .toolbar-controls-button .highlight.active a, -.toolbar-subscribe-button .highlight.active a, -.toolbar-controls .toolbar-subscribe-button .highlight.active a, -.options-menu li .highlight.active a, -.options-submenu li .highlight.active a, -.plusminus .task-checker .highlight.active button, -.task-options .priority-multiplier li .highlight.active button, -.task-options .task-attributes li .highlight.active button, -.task-options .repeat-days li .highlight.active button, -.task-options .repeat-frequency li .highlight.active button, -.task-options .save-close .highlight.active button, -.task-checklist-edit > ul li .highlight.active button, -.filters .filters-tags .highlight.active button, -.chat-buttons .highlight.active button, -.toolbar-mobile .highlight.active button, -.toolbar-nav .toolbar-button .highlight.active button, -.toolbar-sync .highlight.active button, -.toolbar-settings .highlight.active button, -.toolbar-audio .highlight.active button, -.toolbar-notifs .highlight.active button, -.toolbar-controls .toolbar-controls-button .highlight.active button, -.toolbar-subscribe-button .highlight.active button, -.toolbar-controls .toolbar-subscribe-button .highlight.active button, -.options-menu li .highlight.active button, -.options-submenu li .highlight.active button { - background-color: #e9efef !important; - border-color: #678e8e !important; -} -.plusminus .task-checker .highlight.active.filters-tags a, -.task-options .priority-multiplier li .highlight.active.filters-tags a, -.task-options .task-attributes li .highlight.active.filters-tags a, -.task-options .repeat-days li .highlight.active.filters-tags a, -.task-options .repeat-frequency li .highlight.active.filters-tags a, -.task-options .save-close .highlight.active.filters-tags a, -.task-checklist-edit > ul li .highlight.active.filters-tags a, -.filters .filters-tags .highlight.active.filters-tags a, -.chat-buttons .highlight.active.filters-tags a, -.toolbar-mobile .highlight.active.filters-tags a, -.toolbar-nav .toolbar-button .highlight.active.filters-tags a, -.toolbar-sync .highlight.active.filters-tags a, -.toolbar-settings .highlight.active.filters-tags a, -.toolbar-audio .highlight.active.filters-tags a, -.toolbar-notifs .highlight.active.filters-tags a, -.toolbar-controls .toolbar-controls-button .highlight.active.filters-tags a, -.toolbar-subscribe-button .highlight.active.filters-tags a, -.toolbar-controls .toolbar-subscribe-button .highlight.active.filters-tags a, -.options-menu li .highlight.active.filters-tags a, -.options-submenu li .highlight.active.filters-tags a, -.plusminus .task-checker .highlight.active.filters-tags button, -.task-options .priority-multiplier li .highlight.active.filters-tags button, -.task-options .task-attributes li .highlight.active.filters-tags button, -.task-options .repeat-days li .highlight.active.filters-tags button, -.task-options .repeat-frequency li .highlight.active.filters-tags button, -.task-options .save-close .highlight.active.filters-tags button, -.task-checklist-edit > ul li .highlight.active.filters-tags button, -.filters .filters-tags .highlight.active.filters-tags button, -.chat-buttons .highlight.active.filters-tags button, -.toolbar-mobile .highlight.active.filters-tags button, -.toolbar-nav .toolbar-button .highlight.active.filters-tags button, -.toolbar-sync .highlight.active.filters-tags button, -.toolbar-settings .highlight.active.filters-tags button, -.toolbar-audio .highlight.active.filters-tags button, -.toolbar-notifs .highlight.active.filters-tags button, -.toolbar-controls .toolbar-controls-button .highlight.active.filters-tags button, -.toolbar-subscribe-button .highlight.active.filters-tags button, -.toolbar-controls .toolbar-subscribe-button .highlight.active.filters-tags button, -.options-menu li .highlight.active.filters-tags button, -.options-submenu li .highlight.active.filters-tags button { - background-color: #97b3b3 !important; - border-color: #f4f7f7 !important; - color: #fff !important; -} -.plusminus .task-checker .highlight.active.filters-tags a span, -.task-options .priority-multiplier li .highlight.active.filters-tags a span, -.task-options .task-attributes li .highlight.active.filters-tags a span, -.task-options .repeat-days li .highlight.active.filters-tags a span, -.task-options .repeat-frequency li .highlight.active.filters-tags a span, -.task-options .save-close .highlight.active.filters-tags a span, -.task-checklist-edit > ul li .highlight.active.filters-tags a span, -.filters .filters-tags .highlight.active.filters-tags a span, -.chat-buttons .highlight.active.filters-tags a span, -.toolbar-mobile .highlight.active.filters-tags a span, -.toolbar-nav .toolbar-button .highlight.active.filters-tags a span, -.toolbar-sync .highlight.active.filters-tags a span, -.toolbar-settings .highlight.active.filters-tags a span, -.toolbar-audio .highlight.active.filters-tags a span, -.toolbar-notifs .highlight.active.filters-tags a span, -.toolbar-controls .toolbar-controls-button .highlight.active.filters-tags a span, -.toolbar-subscribe-button .highlight.active.filters-tags a span, -.toolbar-controls .toolbar-subscribe-button .highlight.active.filters-tags a span, -.options-menu li .highlight.active.filters-tags a span, -.options-submenu li .highlight.active.filters-tags a span, -.plusminus .task-checker .highlight.active.filters-tags button span, -.task-options .priority-multiplier li .highlight.active.filters-tags button span, -.task-options .task-attributes li .highlight.active.filters-tags button span, -.task-options .repeat-days li .highlight.active.filters-tags button span, -.task-options .repeat-frequency li .highlight.active.filters-tags button span, -.task-options .save-close .highlight.active.filters-tags button span, -.task-checklist-edit > ul li .highlight.active.filters-tags button span, -.filters .filters-tags .highlight.active.filters-tags button span, -.chat-buttons .highlight.active.filters-tags button span, -.toolbar-mobile .highlight.active.filters-tags button span, -.toolbar-nav .toolbar-button .highlight.active.filters-tags button span, -.toolbar-sync .highlight.active.filters-tags button span, -.toolbar-settings .highlight.active.filters-tags button span, -.toolbar-audio .highlight.active.filters-tags button span, -.toolbar-notifs .highlight.active.filters-tags button span, -.toolbar-controls .toolbar-controls-button .highlight.active.filters-tags button span, -.toolbar-subscribe-button .highlight.active.filters-tags button span, -.toolbar-controls .toolbar-subscribe-button .highlight.active.filters-tags button span, -.options-menu li .highlight.active.filters-tags button span, -.options-submenu li .highlight.active.filters-tags button span { - color: #fff !important; -} -.toolbar-nav .toolbar-button-dropdown { - border: 1px solid #ccc !important; - -webkit-border-radius: 0.382em !important; - border-radius: 0.382em !important; -} -.toolbar-nav .toolbar-button-dropdown > a:first-of-type { - -webkit-border-radius: 0.382em 0em 0em 0.382em !important; - border-radius: 0.382em 0em 0em 0.382em !important; -} -.toolbar-nav .toolbar-button-dropdown > a:last-of-type { - -webkit-border-radius: 0em 0.382em 0.382em 0em !important; - border-radius: 0em 0.382em 0.382em 0em !important; -} -.toolbar-nav .toolbar-button-dropdown.highlight { - border-color: #89aef0 !important; - background-color: #c9daf8 !important; - background-color: #c9daf8 !important; -} -.toolbar-nav .toolbar-button-dropdown.highlight { - color: #3e5555 !important; -} -.toolbar-nav .toolbar-button-dropdown.highlight:active { - background-color: #14459a !important; -} -@media screen and (min-width: 768px) { - .toolbar-nav .toolbar-button-dropdown.highlight:hover { - background-color: #c0d4f7 !important; - } -} -.toolbar-nav .toolbar-button-dropdown.highlight > a, -.toolbar-nav .toolbar-button-dropdown.highlight > button { - background-color: #c9daf8 !important; -} -.toolbar-nav .toolbar-button-dropdown.highlight > a:active, -.toolbar-nav .toolbar-button-dropdown.highlight > button:active { - background-color: #14459a !important; -} -@media screen and (min-width: 768px) { - .toolbar-nav .toolbar-button-dropdown.highlight > a:hover, - .toolbar-nav .toolbar-button-dropdown.highlight > button:hover { - background-color: #c0d4f7 !important; - } -} -.toolbar-nav .toolbar-button-dropdown.highlight > a, -.toolbar-nav .toolbar-button-dropdown.highlight > button, -.toolbar-nav .toolbar-button-dropdown.highlight > input, -.toolbar-nav .toolbar-button-dropdown.highlight textarea { - color: #3e5555 !important; - border-color: #c6d6d6 !important; -} -.toolbar-nav .toolbar-button-dropdown.highlight > input:hover, -.toolbar-nav .toolbar-button-dropdown.highlight textarea:hover { - border-color: #97b3b3 !important; -} -.toolbar-nav .toolbar-button-dropdown.highlight > input:focus, -.toolbar-nav .toolbar-button-dropdown.highlight textarea:focus { - border-color: #4f6d6d !important; - outline: none; -} -.toolbar-nav .toolbar-button-dropdown.highlight > input + button:focus, -.toolbar-nav .toolbar-button-dropdown.highlight textarea + button:focus { - border-color: #97b3b3 !important; - background-color: #e2eaea !important; - outline: none; -} -.toolbar-nav .toolbar-button-dropdown.highlight > input + button:active, -.toolbar-nav .toolbar-button-dropdown.highlight textarea + button:active { - background-color: #c6d6d6 !important; -} -.toolbar-nav .toolbar-button-dropdown.highlight > a:nth-of-type(2) { - border-left: 1px solid #bad0f6 !important; -} -@media screen and (min-width: 768px) { - .toolbar-nav .toolbar-button-dropdown.highlight > div { - border: 1px solid #c6d6d6; - } -} -.toolbar-nav .toolbar-button-dropdown.highlight > div h4 { - color: #4f6d6d; - background-color: #f4f7f7; - border-color: #c6d6d6; -} -.toolbar-nav .toolbar-button-dropdown.highlight > div > ul:first-child:before, -.toolbar-nav .toolbar-button-dropdown.highlight > div > div:first-child:before { - background-color: #fff; - border-color: #c6d6d6; -} -.toolbar-nav .toolbar-button-dropdown.highlight > div h4:before { - background-color: #f4f7f7; - border-color: #c6d6d6; -} -.toolbar-nav .toolbar-button-dropdown.highlight > div ul { - border-color: #f4f7f7; -} -.toolbar-nav .toolbar-button-dropdown.highlight > div a, -.toolbar-nav .toolbar-button-dropdown.highlight > div a:link, -.toolbar-nav .toolbar-button-dropdown.highlight > div a:visited { - color: #3e5555 !important; -} -.toolbar-nav .toolbar-button-dropdown.highlight .glyphicon { - color: #537272; -} -.toolbar-nav .toolbar-button-dropdown.highlight .tooltip { - border: 0; -} -.toolbar-nav .toolbar-button-dropdown.highlight.active a, -.toolbar-nav .toolbar-button-dropdown.highlight.active button { - background-color: #e9efef !important; - border-color: #678e8e !important; -} -.toolbar-nav .toolbar-button-dropdown.highlight.active.filters-tags a, -.toolbar-nav .toolbar-button-dropdown.highlight.active.filters-tags button { - background-color: #97b3b3 !important; - border-color: #f4f7f7 !important; - color: #fff !important; -} -.toolbar-nav .toolbar-button-dropdown.highlight.active.filters-tags a span, -.toolbar-nav .toolbar-button-dropdown.highlight.active.filters-tags button span { - color: #fff !important; -} -.task-add, -.task-checklist-edit > .checklist-form li, -.filters .hrpg-input-group { - border: none; - -webkit-border-radius: 0.382em; - border-radius: 0.382em; -} -.task-add input, -.task-checklist-edit > .checklist-form li input, -.filters .hrpg-input-group input, -.task-add a, -.task-checklist-edit > .checklist-form li a, -.filters .hrpg-input-group a, -.task-add button, -.task-checklist-edit > .checklist-form li button, -.filters .hrpg-input-group button, -.task-add textarea, -.task-checklist-edit > .checklist-form li textarea, -.filters .hrpg-input-group textarea { - display: block; - float: left; - height: 2em; -} -.task-add textarea, -.task-checklist-edit > .checklist-form li textarea, -.filters .hrpg-input-group textarea { - height: auto; -} -.task-add input, -.task-checklist-edit > .checklist-form li input, -.filters .hrpg-input-group input, -.task-add textarea, -.task-checklist-edit > .checklist-form li textarea, -.filters .hrpg-input-group textarea { - border: 1px solid #ccc; - -webkit-border-radius: 0.382em 0em 0em 0.382em !important; - border-radius: 0.382em 0em 0em 0.382em !important; - padding-left: 0.618em; - background-color: #fff !important; - -webkit-appearance: none; - -moz-appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - -webkit-box-shadow: none; - box-shadow: none; -} -.task-add a, -.task-checklist-edit > .checklist-form li a, -.filters .hrpg-input-group a, -.task-add button, -.task-checklist-edit > .checklist-form li button, -.filters .hrpg-input-group button { - border-width: 1px; - border-color: #ccc; - border-top-style: solid; - border-right-style: solid; - border-bottom-style: solid; - border-left: none; - -webkit-border-radius: 0em 0.382em 0.382em 0em; - border-radius: 0em 0.382em 0.382em 0em; - outline: none; -} -.task-filter, -.filters .filters-controls { - list-style: none; - display: inline-block; - border: 1px solid #d6d6d6; - -webkit-border-radius: 0.382em; - border-radius: 0.382em; - margin-bottom: 0.618em; -} -.task-filter li, -.filters .filters-controls li { - border-right: 1px solid #f5f5f5; - float: left; - word-wrap: break-word; -} -.task-filter li button, -.filters .filters-controls li button { - border: none; -} -.task-filter li:nth-of-type(2), -.filters .filters-controls li:nth-of-type(2) { - -webkit-border-radius: 0.382em 0em 0em 0.382em; - border-radius: 0.382em 0em 0em 0.382em; -} -.task-filter li:first-of-type, -.filters .filters-controls li:first-of-type { - -webkit-border-radius: 0.382em 0em 0em 0.382em; - border-radius: 0.382em 0em 0em 0.382em; -} -.task-filter li:first-of-type a, -.filters .filters-controls li:first-of-type a, -.task-filter li:first-of-type button, -.filters .filters-controls li:first-of-type button { - -webkit-border-radius: 0.382em 0em 0em 0.382em; - border-radius: 0.382em 0em 0em 0.382em; -} -.task-filter li:last-of-type, -.filters .filters-controls li:last-of-type { - border-right: none; -} -.task-filter li:last-of-type a, -.filters .filters-controls li:last-of-type a, -.task-filter li:last-of-type button, -.filters .filters-controls li:last-of-type button { - -webkit-border-radius: 0em 0.382em 0.382em 0em; - border-radius: 0em 0.382em 0.382em 0em; -} -.filters .filters-controls li:first-of-type { - padding: 0.25em 0.618em; -} -.herobox .avatar-level, -.herobox .avatar-name, -.hero-stats .meter-label { - padding: 0.382em 0.618em; - border: 1px solid #fff; - outline: 1px solid #999; - background-color: #999; - font-size: 0.8em; - line-height: 1; - 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; -} -.herobox .avatar-level > a, -.herobox .avatar-name > a, -.hero-stats .meter-label > a { - color: #fff; -} -.toolbar-mobile, -.toolbar-nav .toolbar-button-dropdown, -.toolbar-notifs, -.toolbar-settings, -.toolbar-audio { - position: relative; -} -.toolbar-mobile > div, -.toolbar-nav .toolbar-button-dropdown > div, -.toolbar-notifs > div, -.toolbar-settings > div, -.toolbar-audio > div { - position: absolute; - top: 2.9em; - min-width: 110px; - -webkit-border-radius: 4px; - border-radius: 4px; - background-color: #fff; -} -@media screen and (max-width: 767px) { - .toolbar-mobile > div, - .toolbar-nav .toolbar-button-dropdown > div, - .toolbar-notifs > div, - .toolbar-settings > div, - .toolbar-audio > div { - position: fixed; - z-index: 1000; - top: 2%; - 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); - } - .toolbar-mobile > div h4:before, - .toolbar-nav .toolbar-button-dropdown > div h4:before, - .toolbar-notifs > div h4:before, - .toolbar-settings > div h4:before, - .toolbar-audio > div h4:before { - display: none; - } - .toolbar-mobile > div > div, - .toolbar-nav .toolbar-button-dropdown > div > div, - .toolbar-notifs > div > div, - .toolbar-settings > div > div, - .toolbar-audio > div > div { - height: 80%; - overflow: auto; - } - .toolbar-mobile > div > div ul:last-child, - .toolbar-nav .toolbar-button-dropdown > div > div ul:last-child, - .toolbar-notifs > div > div ul:last-child, - .toolbar-settings > div > div ul:last-child, - .toolbar-audio > div > div ul:last-child { - padding-bottom: 2.618em; - } -} -.toolbar-mobile > div h4, -.toolbar-nav .toolbar-button-dropdown > div h4, -.toolbar-notifs > div h4, -.toolbar-settings > div h4, -.toolbar-audio > div h4 { - font-size: 1.2em; - margin: 0px; - border-bottom-width: 1px; - border-bottom-style: solid; - -webkit-border-radius: 0.2em 0.2em 0em 0em; - border-radius: 0.2em 0.2em 0em 0em; - padding: 0.618em 0.8333em; -} -.toolbar-mobile > div ul, -.toolbar-nav .toolbar-button-dropdown > div ul, -.toolbar-notifs > div ul, -.toolbar-settings > div ul, -.toolbar-audio > div ul { - padding: 0.382em 0em; - list-style: none; - overflow: auto; - border-top-width: 1px; - border-top-style: solid; -} -@media screen and (min-width: 768px) { - .toolbar-mobile > div ul, - .toolbar-nav .toolbar-button-dropdown > div ul, - .toolbar-notifs > div ul, - .toolbar-settings > div ul, - .toolbar-audio > div ul { - max-height: 320px; - max-height: 62.8vh; - } -} -.toolbar-mobile > div ul li, -.toolbar-nav .toolbar-button-dropdown > div ul li, -.toolbar-notifs > div ul li, -.toolbar-settings > div ul li, -.toolbar-audio > div ul li { - width: 100%; -} -@media screen and (max-width: 767px) { - .toolbar-mobile > div ul li, - .toolbar-nav .toolbar-button-dropdown > div ul li, - .toolbar-notifs > div ul li, - .toolbar-settings > div ul li, - .toolbar-audio > div ul li { - width: auto; - } -} -.toolbar-mobile > div ul a, -.toolbar-nav .toolbar-button-dropdown > div ul a, -.toolbar-notifs > div ul a, -.toolbar-settings > div ul a, -.toolbar-audio > div ul a { - display: inline-block; - width: 100%; - height: 100%; - padding: 0.618em 1em; -} -.toolbar-mobile > div ul:first-of-type, -.toolbar-nav .toolbar-button-dropdown > div ul:first-of-type, -.toolbar-notifs > div ul:first-of-type, -.toolbar-settings > div ul:first-of-type, -.toolbar-audio > div ul:first-of-type { - border: none; -} -.toolbar-mobile > div > ul:first-child:before, -.toolbar-nav .toolbar-button-dropdown > div > ul:first-child:before, -.toolbar-notifs > div > ul:first-child:before, -.toolbar-settings > div > ul:first-child:before, -.toolbar-audio > div > ul:first-child:before, -.toolbar-mobile > div > div:first-child:before, -.toolbar-nav .toolbar-button-dropdown > div > div:first-child:before, -.toolbar-notifs > div > div:first-child:before, -.toolbar-settings > div > div:first-child:before, -.toolbar-audio > div > div:first-child:before, -.toolbar-mobile > div h4:before, -.toolbar-nav .toolbar-button-dropdown > div h4:before, -.toolbar-notifs > div h4:before, -.toolbar-settings > div h4:before, -.toolbar-audio > div h4:before { - content: ''; - position: absolute; - top: -9px; - z-index: 2; - width: 16px; - height: 16px; - border-top-width: 1px; - border-top-style: solid; - border-left-width: 1px; - border-left-style: solid; - -webkit-transform: rotate(45deg); - -moz-transform: rotate(45deg); - -o-transform: rotate(45deg); - -ms-transform: rotate(45deg); - transform: rotate(45deg); -} -@media screen and (min-width: 768px) { - .toolbar-notifs div, - .toolbar-settings div, - .toolbar-audio div { - right: 0px; - } - .toolbar-notifs div > ul:first-child:before, - .toolbar-settings div > ul:first-child:before, - .toolbar-audio div > ul:first-child:before, - .toolbar-notifs div > div:first-child:before, - .toolbar-settings div > div:first-child:before, - .toolbar-audio div > div:first-child:before, - .toolbar-notifs div h4:before, - .toolbar-settings div h4:before, - .toolbar-audio div h4:before { - right: 0.5em; - } -} -@media screen and (min-width: 768px) { - .toolbar-mobile div, - .toolbar-nav .toolbar-button-dropdown div { - left: 0px; - } - .toolbar-mobile div > ul:first-child:before, - .toolbar-nav .toolbar-button-dropdown div > ul:first-child:before, - .toolbar-mobile div > div:first-child:before, - .toolbar-nav .toolbar-button-dropdown div > div:first-child:before, - .toolbar-mobile div h4:before, - .toolbar-nav .toolbar-button-dropdown div h4:before { - left: 1em; - } -} -.toolbar-controls { - text-align: right; - -webkit-border-radius: 0px 0px 0.382em 0.382em; - border-radius: 0px 0px 0.382em 0.382em; - background-color: #fff; -} -.toolbar-controls li { - margin-right: 0.618em; - margin-bottom: 0.382em; - display: inline-block; -} -@media screen and (max-width: 767px) { - .toolbar-controls, -.toolbar-controls { - width: 96%; - position: fixed; - bottom: 2%; - right: 2%; - border: none; - } -} -@media screen and (min-width: 768px) { - .toolbar-controls, -.toolbar-controls { - display: none; - } -} -.options-menu, -.options-submenu { - margin-bottom: 1.618em; -} -@media screen and (min-width: 768px) { - .options-menu, - .options-submenu, -.options-menu, -.options-submenu { - padding: 1em 1em 0em 1em; - } -} -.options-menu li, -.options-submenu li { - display: inline-block; - margin-right: 1em; - margin-bottom: 1em; -} -.disabled-tooltip-wrapper { - display: inline-block; -} -.task-column:not(.rewards) .color-worst:not(.completed) { - background-color: #e6b8af; - border: 1px solid #cfa59d; -} -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > a, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > a, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > a, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > a, -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > button, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > button, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > button, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > button { - background-color: #e6b8af !important; -} -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > a:active, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > a:active, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > a:active, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > a:active, -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > button:active, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > button:active, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > button:active, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > button:active { - background-color: #783325 !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > a:hover, - .task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > a:hover, - .task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > a:hover, - .task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > a:hover, - .task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > button:hover, - .task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > button:hover, - .task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > button:hover, - .task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > button:hover { - background-color: #e4b2a8 !important; - } -} -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > a, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > a, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > a, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > a, -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > button, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > button, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > button, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > button, -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > input, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > input, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > input, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > input, -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li textarea, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li textarea, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li textarea, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li textarea { - color: #5d271d !important; - border-color: #d68c7d !important; -} -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > input:hover, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > input:hover, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > input:hover, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > input:hover, -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li textarea:hover, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li textarea:hover, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li textarea:hover, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li textarea:hover { - border-color: #c65e4a !important; -} -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > input:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > input:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > input:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > input:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li textarea:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li textarea:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li textarea:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li textarea:focus { - 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) .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) .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 { - 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) .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-days 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), -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > a:nth-of-type(2), -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > a:nth-of-type(2), -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > a:nth-of-type(2) { - border-left: 1px solid #e2aea3 !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > div, - .task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > div, - .task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > div, - .task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > div { - border: 1px solid #d68c7d; - } -} -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > div h4, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > div h4, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > div h4, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > div h4 { - color: #763225; - 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 { - background-color: #fff; - border-color: #d68c7d; -} -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > div h4:before, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > div h4:before, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > div h4:before, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > div h4:before { - background-color: #e6b8af; - border-color: #d68c7d; -} -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > div ul, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > div ul, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > div ul, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > div ul { - border-color: #e6b8af; -} -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > div a, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > div a, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > div a, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > div a, -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > div a:link, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > div a:link, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > div a:link, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > div a:link, -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li > div a:visited, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li > div a:visited, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li > div a:visited, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li > div a:visited { - color: #5d271d !important; -} -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li .glyphicon, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li .glyphicon, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li .glyphicon, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li .glyphicon { - color: #7b3427; -} -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li .tooltip, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li .tooltip, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li .tooltip, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li .tooltip { - border: 0; -} -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li.active a, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li.active a, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li.active a, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li.active a, -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li.active button, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li.active button, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li.active button, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li.active button { - 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) .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) .repeat-days 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) .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 { - color: #fff !important; -} -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li button.active, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li button.active, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li button.active, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li button.active { - -webkit-box-shadow: inset 0 0 0 1px #b94f3a !important; - box-shadow: inset 0 0 0 1px #b94f3a !important; - background-color: #e1aaa0 !important; -} -.task-column:not(.rewards) .color-worst:not(.completed) .priority-multiplier li button:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .task-attributes li button:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-days li button:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .repeat-frequency li button:focus { - border: 1px solid #c96652; - outline: 0; -} -.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 { - -webkit-box-shadow: inset 0 0 0 1px #b94f3a !important; - box-shadow: inset 0 0 0 1px #b94f3a !important; - background-color: #e1aaa0 !important; -} -.task-column:not(.rewards) .color-worst:not(.completed) .save-close > a, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > a, -.task-column:not(.rewards) .color-worst:not(.completed) .save-close > button, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > button { - background-color: #e1aaa0 !important; -} -.task-column:not(.rewards) .color-worst:not(.completed) .save-close > a:active, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > a:active, -.task-column:not(.rewards) .color-worst:not(.completed) .save-close > button:active, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > button:active { - background-color: #723024 !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-worst:not(.completed) .save-close > a:hover, - .task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > a:hover, - .task-column:not(.rewards) .color-worst:not(.completed) .save-close > button:hover, - .task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > button:hover { - background-color: #dfa499 !important; - } -} -.task-column:not(.rewards) .color-worst:not(.completed) .save-close > a, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > a, -.task-column:not(.rewards) .color-worst:not(.completed) .save-close > button, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > button, -.task-column:not(.rewards) .color-worst:not(.completed) .save-close > input, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > input, -.task-column:not(.rewards) .color-worst:not(.completed) .save-close textarea, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li textarea { - color: #58251c !important; - border-color: #d28071 !important; -} -.task-column:not(.rewards) .color-worst:not(.completed) .save-close > input:hover, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > input:hover, -.task-column:not(.rewards) .color-worst:not(.completed) .save-close textarea:hover, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li textarea:hover { - border-color: #c35440 !important; -} -.task-column:not(.rewards) .color-worst:not(.completed) .save-close > input:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > input:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .save-close textarea:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li textarea:focus { - border-color: #702f23 !important; - 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) .save-close 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) .save-close 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), -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > a:nth-of-type(2) { - border-left: 1px solid #dda095 !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-worst:not(.completed) .save-close > div, - .task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > div { - border: 1px solid #d28071; - } -} -.task-column:not(.rewards) .color-worst:not(.completed) .save-close > div h4, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > div h4 { - color: #702f23; - background-color: #e1aaa0; - 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) .save-close > 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; -} -.task-column:not(.rewards) .color-worst:not(.completed) .save-close > div h4:before, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > div h4:before { - background-color: #e1aaa0; - border-color: #d28071; -} -.task-column:not(.rewards) .color-worst:not(.completed) .save-close > div ul, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > div ul { - border-color: #e1aaa0; -} -.task-column:not(.rewards) .color-worst:not(.completed) .save-close > div a, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > div a, -.task-column:not(.rewards) .color-worst:not(.completed) .save-close > div a:link, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > div a:link, -.task-column:not(.rewards) .color-worst:not(.completed) .save-close > div a:visited, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li > div a:visited { - color: #58251c !important; -} -.task-column:not(.rewards) .color-worst:not(.completed) .save-close .glyphicon, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li .glyphicon { - color: #753125; -} -.task-column:not(.rewards) .color-worst:not(.completed) .save-close .tooltip, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li .tooltip { - border: 0; -} -.task-column:not(.rewards) .color-worst:not(.completed) .save-close.active a, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li.active a, -.task-column:not(.rewards) .color-worst:not(.completed) .save-close.active button, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li.active button { - background-color: #dda095 !important; - 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) .save-close.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) .save-close.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, -.task-column:not(.rewards) .color-worst:not(.completed) .task-checklist-edit li button:focus { - border: 1px solid #c96652; - outline: 0; -} -.task-column:not(.rewards) .color-worst:not(.completed) .task-actions { - background-color: #c96652; -} -.task-column:not(.rewards) .color-worst:not(.completed) .action-yesno label, -.task-column:not(.rewards) .color-worst:not(.completed) .task-action-btn, -.task-column:not(.rewards) .color-worst:not(.completed) .task-actions a { - background-color: #c96652; -} -.task-column:not(.rewards) .color-worst:not(.completed) .action-yesno label:hover, -.task-column:not(.rewards) .color-worst:not(.completed) .task-action-btn:hover, -.task-column:not(.rewards) .color-worst:not(.completed) .task-actions a:hover, -.task-column:not(.rewards) .color-worst:not(.completed) .action-yesno label:focus, -.task-column:not(.rewards) .color-worst:not(.completed) .task-action-btn:focus, -.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.habit:focus + a { - background-color: #b94f3a; -} -.task-column:not(.rewards) .color-worst:not(.completed) .task-actions a:nth-of-type(2) { - border-top: 1px solid #9a4230; -} -.task-column:not(.rewards) .color-worst:not(.completed) .task-options { - background-color: #e6b8af; -} -.task-column:not(.rewards) .color-worst:not(.completed) .option-group:not(.task-checklist) { - border-bottom: 1px solid #d88f81; -} -.task-column:not(.rewards) .color-worst:not(.completed) .option-content { - border-color: #d68c7d !important; -} -.task-column:not(.rewards) .color-worst:not(.completed) .option-content:hover { - border-color: #c65e4a !important; -} -.task-column:not(.rewards) .color-worst:not(.completed) .option-content:focus { - border-color: #763225 !important; - outline: none; -} -.task-column:not(.rewards) .color-worse:not(.completed) { - background-color: #f4cccc; - border: 1px solid #dbb7b7; -} -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > a, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > a, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > a, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > a, -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > button, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > button, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > button, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > button { - background-color: #f4cccc !important; -} -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > a:active, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > a:active, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > a:active, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > a:active, -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > button:active, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > button:active, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > button:active, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > button:active { - background-color: #8f1f1f !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > a:hover, - .task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > a:hover, - .task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > a:hover, - .task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > a:hover, - .task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > button:hover, - .task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > button:hover, - .task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > button:hover, - .task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > button:hover { - background-color: #f2c3c3 !important; - } -} -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > a, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > a, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > a, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > a, -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > button, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > button, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > button, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > button, -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > input, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > input, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > input, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > input, -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li textarea, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li textarea, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li textarea, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li textarea { - color: #6f1818 !important; - border-color: #e79090 !important; -} -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > input:hover, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > input:hover, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > input:hover, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > input:hover, -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li textarea:hover, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li textarea:hover, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li textarea:hover, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li textarea:hover { - border-color: #da5353 !important; -} -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > input:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > input:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > input:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > input:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li textarea:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li textarea:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li textarea:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li textarea:focus { - 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) .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) .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 { - 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) .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-days 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), -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > a:nth-of-type(2), -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > a:nth-of-type(2), -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > a:nth-of-type(2) { - border-left: 1px solid #f1bebe !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > div, - .task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > div, - .task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > div, - .task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > div { - border: 1px solid #e79090; - } -} -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > div h4, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > div h4, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > div h4, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > div h4 { - color: #8d1e1e; - 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 { - background-color: #fff; - border-color: #e79090; -} -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > div h4:before, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > div h4:before, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > div h4:before, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > div h4:before { - background-color: #f4cccc; - border-color: #e79090; -} -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > div ul, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > div ul, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > div ul, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > div ul { - border-color: #f4cccc; -} -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > div a, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > div a, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > div a, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > div a, -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > div a:link, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > div a:link, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > div a:link, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > div a:link, -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li > div a:visited, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li > div a:visited, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li > div a:visited, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li > div a:visited { - color: #6f1818 !important; -} -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li .glyphicon, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li .glyphicon, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li .glyphicon, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li .glyphicon { - color: #932020; -} -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li .tooltip, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li .tooltip, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li .tooltip, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li .tooltip { - border: 0; -} -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li.active a, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li.active a, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li.active a, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li.active a, -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li.active button, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li.active button, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li.active button, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li.active button { - 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) .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) .repeat-days 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) .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 { - color: #fff !important; -} -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li button.active, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li button.active, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li button.active, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li button.active { - -webkit-box-shadow: inset 0 0 0 1px #d43939 !important; - box-shadow: inset 0 0 0 1px #d43939 !important; - background-color: #f0baba !important; -} -.task-column:not(.rewards) .color-worse:not(.completed) .priority-multiplier li button:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .task-attributes li button:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-days li button:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .repeat-frequency li button:focus { - border: 1px solid #dc5d5d; - outline: 0; -} -.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 { - -webkit-box-shadow: inset 0 0 0 1px #d43939 !important; - box-shadow: inset 0 0 0 1px #d43939 !important; - background-color: #f0baba !important; -} -.task-column:not(.rewards) .color-worse:not(.completed) .save-close > a, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > a, -.task-column:not(.rewards) .color-worse:not(.completed) .save-close > button, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > button { - background-color: #f0baba !important; -} -.task-column:not(.rewards) .color-worse:not(.completed) .save-close > a:active, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > a:active, -.task-column:not(.rewards) .color-worse:not(.completed) .save-close > button:active, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > button:active { - background-color: #881e1e !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-worse:not(.completed) .save-close > a:hover, - .task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > a:hover, - .task-column:not(.rewards) .color-worse:not(.completed) .save-close > button:hover, - .task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > button:hover { - background-color: #eeb2b2 !important; - } -} -.task-column:not(.rewards) .color-worse:not(.completed) .save-close > a, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > a, -.task-column:not(.rewards) .color-worse:not(.completed) .save-close > button, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > button, -.task-column:not(.rewards) .color-worse:not(.completed) .save-close > input, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > input, -.task-column:not(.rewards) .color-worse:not(.completed) .save-close textarea, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li textarea { - color: #691717 !important; - border-color: #e48181 !important; -} -.task-column:not(.rewards) .color-worse:not(.completed) .save-close > input:hover, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > input:hover, -.task-column:not(.rewards) .color-worse:not(.completed) .save-close textarea:hover, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li textarea:hover { - border-color: #d74747 !important; -} -.task-column:not(.rewards) .color-worse:not(.completed) .save-close > input:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > input:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .save-close textarea:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li textarea:focus { - border-color: #861d1d !important; - 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) .save-close 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) .save-close 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), -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > a:nth-of-type(2) { - border-left: 1px solid #edadad !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-worse:not(.completed) .save-close > div, - .task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > div { - border: 1px solid #e48181; - } -} -.task-column:not(.rewards) .color-worse:not(.completed) .save-close > div h4, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > div h4 { - color: #861d1d; - background-color: #f0baba; - 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) .save-close > 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; -} -.task-column:not(.rewards) .color-worse:not(.completed) .save-close > div h4:before, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > div h4:before { - background-color: #f0baba; - border-color: #e48181; -} -.task-column:not(.rewards) .color-worse:not(.completed) .save-close > div ul, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > div ul { - border-color: #f0baba; -} -.task-column:not(.rewards) .color-worse:not(.completed) .save-close > div a, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > div a, -.task-column:not(.rewards) .color-worse:not(.completed) .save-close > div a:link, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > div a:link, -.task-column:not(.rewards) .color-worse:not(.completed) .save-close > div a:visited, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li > div a:visited { - color: #691717 !important; -} -.task-column:not(.rewards) .color-worse:not(.completed) .save-close .glyphicon, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li .glyphicon { - color: #8c1e1e; -} -.task-column:not(.rewards) .color-worse:not(.completed) .save-close .tooltip, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li .tooltip { - border: 0; -} -.task-column:not(.rewards) .color-worse:not(.completed) .save-close.active a, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li.active a, -.task-column:not(.rewards) .color-worse:not(.completed) .save-close.active button, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li.active button { - background-color: #edadad !important; - 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) .save-close.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) .save-close.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, -.task-column:not(.rewards) .color-worse:not(.completed) .task-checklist-edit li button:focus { - border: 1px solid #dc5d5d; - outline: 0; -} -.task-column:not(.rewards) .color-worse:not(.completed) .task-actions { - background-color: #dc5d5d; -} -.task-column:not(.rewards) .color-worse:not(.completed) .action-yesno label, -.task-column:not(.rewards) .color-worse:not(.completed) .task-action-btn, -.task-column:not(.rewards) .color-worse:not(.completed) .task-actions a { - background-color: #dc5d5d; -} -.task-column:not(.rewards) .color-worse:not(.completed) .action-yesno label:hover, -.task-column:not(.rewards) .color-worse:not(.completed) .task-action-btn:hover, -.task-column:not(.rewards) .color-worse:not(.completed) .task-actions a:hover, -.task-column:not(.rewards) .color-worse:not(.completed) .action-yesno label:focus, -.task-column:not(.rewards) .color-worse:not(.completed) .task-action-btn:focus, -.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.habit:focus + a { - background-color: #d43939; -} -.task-column:not(.rewards) .color-worse:not(.completed) .task-actions a:nth-of-type(2) { - border-top: 1px solid #b82828; -} -.task-column:not(.rewards) .color-worse:not(.completed) .task-options { - background-color: #f4cccc; -} -.task-column:not(.rewards) .color-worse:not(.completed) .option-group:not(.task-checklist) { - border-bottom: 1px solid #e89595; -} -.task-column:not(.rewards) .color-worse:not(.completed) .option-content { - border-color: #e79090 !important; -} -.task-column:not(.rewards) .color-worse:not(.completed) .option-content:hover { - border-color: #da5353 !important; -} -.task-column:not(.rewards) .color-worse:not(.completed) .option-content:focus { - border-color: #8d1e1e !important; - outline: none; -} -.task-column:not(.rewards) .color-bad:not(.completed) { - background-color: #fce5cd; - border: 1px solid #e2ceb8; -} -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > a, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > a, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > a, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > a, -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > button, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > button, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > button, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > button { - background-color: #fce5cd !important; -} -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > a:active, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > a:active, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > a:active, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > a:active, -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > button:active, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > button:active, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > button:active, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > button:active { - background-color: #a75a0a !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > a:hover, - .task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > a:hover, - .task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > a:hover, - .task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > a:hover, - .task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > button:hover, - .task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > button:hover, - .task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > button:hover, - .task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > button:hover { - background-color: #fbe0c3 !important; - } -} -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > a, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > a, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > a, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > a, -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > button, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > button, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > button, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > button, -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > input, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > input, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > input, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > input, -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li textarea, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li textarea, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li textarea, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li textarea { - color: #814608 !important; - border-color: #f8c187 !important; -} -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > input:hover, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > input:hover, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > input:hover, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > input:hover, -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li textarea:hover, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li textarea:hover, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li textarea:hover, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li textarea:hover { - border-color: #f49b40 !important; -} -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > input:focus, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > input:focus, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > input:focus, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > input:focus, -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li textarea:focus, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li textarea:focus, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li textarea:focus, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li textarea:focus { - 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) .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) .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 { - border-color: #f49b40 !important; - 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) .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) .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 { - background-color: #f8c187 !important; -} -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > a:nth-of-type(2), -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > a:nth-of-type(2), -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > a:nth-of-type(2), -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > a:nth-of-type(2) { - border-left: 1px solid #fbdcbd !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > div, - .task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > div, - .task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > div, - .task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > div { - border: 1px solid #f8c187; - } -} -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > div h4, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > div h4, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > div h4, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > div h4 { - color: #a5590a; - 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) .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 { - background-color: #fff; - border-color: #f8c187; -} -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > div h4:before, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > div h4:before, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > div h4:before, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > div h4:before { - background-color: #fce5cd; - border-color: #f8c187; -} -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > div ul, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > div ul, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > div ul, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > div ul { - border-color: #fce5cd; -} -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > div a, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > div a, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > div a, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > div a, -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > div a:link, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > div a:link, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > div a:link, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > div a:link, -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li > div a:visited, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li > div a:visited, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li > div a:visited, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li > div a:visited { - color: #814608 !important; -} -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li .glyphicon, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li .glyphicon, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li .glyphicon, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li .glyphicon { - color: #ac5d0a; -} -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li .tooltip, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li .tooltip, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li .tooltip, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li .tooltip { - border: 0; -} -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li.active a, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li.active a, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li.active a, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li.active a, -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li.active button, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li.active button, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li.active button, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li.active button { - background-color: #fbdcbd !important; - border-color: #d8740d !important; -} -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li.active.filters-tags a, -.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) .repeat-days 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) .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 { - color: #fff !important; -} -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li button.active, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li button.active, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li button.active, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li button.active { - -webkit-box-shadow: inset 0 0 0 1px #f28b21 !important; - box-shadow: inset 0 0 0 1px #f28b21 !important; - background-color: #fbdab7 !important; -} -.task-column:not(.rewards) .color-bad:not(.completed) .priority-multiplier li button:focus, -.task-column:not(.rewards) .color-bad:not(.completed) .task-attributes li button:focus, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-days li button:focus, -.task-column:not(.rewards) .color-bad:not(.completed) .repeat-frequency li button:focus { - border: 1px solid #f4a24c; - outline: 0; -} -.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 { - -webkit-box-shadow: inset 0 0 0 1px #f28b21 !important; - box-shadow: inset 0 0 0 1px #f28b21 !important; - background-color: #fbdab7 !important; -} -.task-column:not(.rewards) .color-bad:not(.completed) .save-close > a, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > a, -.task-column:not(.rewards) .color-bad:not(.completed) .save-close > button, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > button { - background-color: #fbdab7 !important; -} -.task-column:not(.rewards) .color-bad:not(.completed) .save-close > a:active, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > a:active, -.task-column:not(.rewards) .color-bad:not(.completed) .save-close > button:active, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > button:active { - background-color: #a05609 !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-bad:not(.completed) .save-close > a:hover, - .task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > a:hover, - .task-column:not(.rewards) .color-bad:not(.completed) .save-close > button:hover, - .task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > button:hover { - background-color: #fad5ad !important; - } -} -.task-column:not(.rewards) .color-bad:not(.completed) .save-close > a, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > a, -.task-column:not(.rewards) .color-bad:not(.completed) .save-close > button, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > button, -.task-column:not(.rewards) .color-bad:not(.completed) .save-close > input, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > input, -.task-column:not(.rewards) .color-bad:not(.completed) .save-close textarea, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li textarea { - color: #7b4307 !important; - border-color: #f7b874 !important; -} -.task-column:not(.rewards) .color-bad:not(.completed) .save-close > input:hover, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > input:hover, -.task-column:not(.rewards) .color-bad:not(.completed) .save-close textarea:hover, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li textarea:hover { - border-color: #f49530 !important; -} -.task-column:not(.rewards) .color-bad:not(.completed) .save-close > input:focus, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > input:focus, -.task-column:not(.rewards) .color-bad:not(.completed) .save-close textarea:focus, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li textarea:focus { - border-color: #9d5509 !important; - 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) .save-close 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) .save-close 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), -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > a:nth-of-type(2) { - border-left: 1px solid #fad2a7 !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-bad:not(.completed) .save-close > div, - .task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > div { - border: 1px solid #f7b874; - } -} -.task-column:not(.rewards) .color-bad:not(.completed) .save-close > div h4, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > div h4 { - color: #9d5509; - background-color: #fbdab7; - 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) .save-close > 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; -} -.task-column:not(.rewards) .color-bad:not(.completed) .save-close > div h4:before, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > div h4:before { - background-color: #fbdab7; - border-color: #f7b874; -} -.task-column:not(.rewards) .color-bad:not(.completed) .save-close > div ul, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > div ul { - border-color: #fbdab7; -} -.task-column:not(.rewards) .color-bad:not(.completed) .save-close > div a, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > div a, -.task-column:not(.rewards) .color-bad:not(.completed) .save-close > div a:link, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > div a:link, -.task-column:not(.rewards) .color-bad:not(.completed) .save-close > div a:visited, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li > div a:visited { - color: #7b4307 !important; -} -.task-column:not(.rewards) .color-bad:not(.completed) .save-close .glyphicon, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li .glyphicon { - color: #a45909; -} -.task-column:not(.rewards) .color-bad:not(.completed) .save-close .tooltip, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li .tooltip { - border: 0; -} -.task-column:not(.rewards) .color-bad:not(.completed) .save-close.active a, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li.active a, -.task-column:not(.rewards) .color-bad:not(.completed) .save-close.active button, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li.active button { - background-color: #fad2a7 !important; - border-color: #ce6f0b !important; -} -.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 { - 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) .save-close.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, -.task-column:not(.rewards) .color-bad:not(.completed) .task-checklist-edit li button:focus { - border: 1px solid #f4a24c; - outline: 0; -} -.task-column:not(.rewards) .color-bad:not(.completed) .task-actions { - background-color: #f4a24c; -} -.task-column:not(.rewards) .color-bad:not(.completed) .action-yesno label, -.task-column:not(.rewards) .color-bad:not(.completed) .task-action-btn, -.task-column:not(.rewards) .color-bad:not(.completed) .task-actions a { - background-color: #f4a24c; -} -.task-column:not(.rewards) .color-bad:not(.completed) .action-yesno label:hover, -.task-column:not(.rewards) .color-bad:not(.completed) .task-action-btn:hover, -.task-column:not(.rewards) .color-bad:not(.completed) .task-actions a:hover, -.task-column:not(.rewards) .color-bad:not(.completed) .action-yesno label:focus, -.task-column:not(.rewards) .color-bad:not(.completed) .task-action-btn:focus, -.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.habit:focus + a { - background-color: #f28b21; -} -.task-column:not(.rewards) .color-bad:not(.completed) .task-actions a:nth-of-type(2) { - border-top: 1px solid #d8740d; -} -.task-column:not(.rewards) .color-bad:not(.completed) .task-options { - background-color: #fce5cd; -} -.task-column:not(.rewards) .color-bad:not(.completed) .option-group:not(.task-checklist) { - border-bottom: 1px solid #f8c38c; -} -.task-column:not(.rewards) .color-bad:not(.completed) .option-content { - border-color: #f8c187 !important; -} -.task-column:not(.rewards) .color-bad:not(.completed) .option-content:hover { - border-color: #f49b40 !important; -} -.task-column:not(.rewards) .color-bad:not(.completed) .option-content:focus { - border-color: #a5590a !important; - outline: none; -} -.task-column:not(.rewards) .color-neutral:not(.completed) { - background-color: #fff2cc; - border: 1px solid #e5d9b7; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > a, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > a, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > a, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > a, -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > button, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > button, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > button, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > button { - background-color: #fff2cc !important; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > a:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > a:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > a:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > a:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > button:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > button:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > button:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > button:active { - background-color: #b28500 !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > a:hover, - .task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > a:hover, - .task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > a:hover, - .task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > a:hover, - .task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > button:hover, - .task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > button:hover, - .task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > button:hover, - .task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > button:hover { - background-color: #ffefc1 !important; - } -} -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > a, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > a, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > a, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > a, -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > button, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > button, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > button, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > button, -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > input, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > input, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > input, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > input, -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li textarea, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li textarea, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li textarea, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li textarea { - color: #8a6700 !important; - border-color: #ffdf82 !important; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > input:hover, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > input:hover, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > input:hover, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > input:hover, -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li textarea:hover, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li textarea:hover, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li textarea:hover, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li textarea:hover { - border-color: #ffcc35 !important; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > input:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > input:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > input:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > input:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li textarea:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li textarea:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li textarea:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li textarea:focus { - 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) .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-days 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) .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-days 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) .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) { - border-left: 1px solid #ffeeba !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > div, - .task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > div, - .task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > div, - .task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > div { - border: 1px solid #ffdf82; - } -} -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > div h4, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > div h4, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > div h4, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > div h4 { - color: #af8300; - 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 { - background-color: #fff; - border-color: #ffdf82; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > div h4:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > div h4:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > div h4:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > div h4:before { - background-color: #fff2cc; - border-color: #ffdf82; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > div ul, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > div ul, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > div ul, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > div ul { - border-color: #fff2cc; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > div a, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > div a, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > div a, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > div a, -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > div a:link, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > div a:link, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > div a:link, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > div a:link, -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li > div a:visited, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li > div a:visited, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li > div a:visited, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li > div a:visited { - color: #8a6700 !important; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li .glyphicon, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li .glyphicon, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li .glyphicon, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li .glyphicon { - color: #b88900; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li .tooltip, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li .tooltip, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li .tooltip, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li .tooltip { - border: 0; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li.active a, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li.active a, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li.active a, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li.active a, -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li.active button, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li.active button, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li.active button, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li.active button { - 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) .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 { - 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 { - color: #fff !important; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li button.active, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li button.active, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li button.active, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li button.active { - -webkit-box-shadow: inset 0 0 0 1px #ffc314 !important; - box-shadow: inset 0 0 0 1px #ffc314 !important; - background-color: #ffecb5 !important; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .priority-multiplier li button:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-attributes li button:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-days li button:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .repeat-frequency li button:focus { - border: 1px solid #ffcf42; - outline: 0; -} -.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 { - -webkit-box-shadow: inset 0 0 0 1px #ffc314 !important; - box-shadow: inset 0 0 0 1px #ffc314 !important; - background-color: #ffecb5 !important; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close > a, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > a, -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close > button, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > button { - background-color: #ffecb5 !important; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close > a:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > a:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close > button:active, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > button:active { - background-color: #a97e00 !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-neutral:not(.completed) .save-close > a:hover, - .task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > a:hover, - .task-column:not(.rewards) .color-neutral:not(.completed) .save-close > button:hover, - .task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > button:hover { - background-color: #ffe9ab !important; - } -} -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close > a, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > a, -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close > button, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > button, -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close > input, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > input, -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close textarea, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li textarea { - color: #836100 !important; - border-color: #ffda6e !important; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close > input:hover, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > input:hover, -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close textarea:hover, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li textarea:hover { - border-color: #ffc726 !important; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close > input:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > input:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close textarea:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li textarea:focus { - border-color: #a77c00 !important; - 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) .save-close 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) .save-close 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) { - border-left: 1px solid #ffe8a4 !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-neutral:not(.completed) .save-close > div, - .task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > div { - border: 1px solid #ffda6e; - } -} -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close > div h4, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > div h4 { - color: #a77c00; - background-color: #ffecb5; - 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 { - background-color: #fff; - border-color: #ffda6e; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close > div h4:before, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > div h4:before { - background-color: #ffecb5; - border-color: #ffda6e; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close > div ul, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > div ul { - border-color: #ffecb5; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close > div a, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > div a, -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close > div a:link, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > div a:link, -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close > div a:visited, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li > div a:visited { - color: #836100 !important; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close .glyphicon, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li .glyphicon { - color: #ae8200; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close .tooltip, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li .tooltip { - border: 0; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close.active a, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li.active a, -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close.active button, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li.active button { - background-color: #ffe8a4 !important; - 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) .save-close.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 { - color: #fff !important; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .save-close button:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-checklist-edit li button:focus { - border: 1px solid #ffcf42; - outline: 0; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .task-actions { - background-color: #ffcf42; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .action-yesno label, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-action-btn, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-actions a { - background-color: #ffcf42; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .action-yesno label:hover, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-action-btn:hover, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-actions a:hover, -.task-column:not(.rewards) .color-neutral:not(.completed) .action-yesno label:focus, -.task-column:not(.rewards) .color-neutral:not(.completed) .task-action-btn:focus, -.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.habit:focus + a { - background-color: #ffc314; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .task-actions a:nth-of-type(2) { - border-top: 1px solid #e6ab00; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .task-options { - background-color: #fff2cc; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .option-group:not(.task-checklist) { - border-bottom: 1px solid #ffe087; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .option-content { - border-color: #ffdf82 !important; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .option-content:hover { - border-color: #ffcc35 !important; -} -.task-column:not(.rewards) .color-neutral:not(.completed) .option-content:focus { - border-color: #af8300 !important; - outline: none; -} -.task-column:not(.rewards) .color-good:not(.completed) { - background-color: #d9ead3; - border: 1px solid #c3d2bd; -} -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > a, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > a, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > a, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > a, -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > button, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > button, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > button, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > button { - background-color: #d9ead3 !important; -} -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > a:active, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > a:active, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > a:active, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > a:active, -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > button:active, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > button:active, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > button:active, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > button:active { - background-color: #487538 !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > a:hover, - .task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > a:hover, - .task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > a:hover, - .task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > a:hover, - .task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > button:hover, - .task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > button:hover, - .task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > button:hover, - .task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > button:hover { - background-color: #d3e7cc !important; - } -} -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > a, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > a, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > a, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > a, -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > button, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > button, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > button, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > button, -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > input, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > input, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > input, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > input, -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li textarea, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li textarea, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li textarea, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li textarea { - color: #375a2b !important; - border-color: #afd3a2 !important; -} -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > input:hover, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > input:hover, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > input:hover, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > input:hover, -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li textarea:hover, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li textarea:hover, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li textarea:hover, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li textarea:hover { - border-color: #84bb70 !important; -} -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > input:focus, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > input:focus, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > input:focus, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > input:focus, -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li textarea:focus, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li textarea:focus, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li textarea:focus, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li textarea:focus { - 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) .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) .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 { - 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) .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) .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 { - background-color: #afd3a2 !important; -} -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > a:nth-of-type(2), -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > a:nth-of-type(2), -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > a:nth-of-type(2), -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > a:nth-of-type(2) { - border-left: 1px solid #cfe5c7 !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > div, - .task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > div, - .task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > div, - .task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > div { - border: 1px solid #afd3a2; - } -} -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > div h4, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > div h4, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > div h4, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > div h4 { - color: #477337; - 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 { - background-color: #fff; - border-color: #afd3a2; -} -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > div h4:before, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > div h4:before, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > div h4:before, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > div h4:before { - background-color: #d9ead3; - border-color: #afd3a2; -} -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > div ul, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > div ul, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > div ul, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > div ul { - border-color: #d9ead3; -} -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > div a, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > div a, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > div a, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > div a, -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > div a:link, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > div a:link, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > div a:link, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > div a:link, -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li > div a:visited, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li > div a:visited, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li > div a:visited, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li > div a:visited { - color: #375a2b !important; -} -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li .glyphicon, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li .glyphicon, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li .glyphicon, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li .glyphicon { - color: #4a783a; -} -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li .tooltip, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li .tooltip, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li .tooltip, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li .tooltip { - border: 0; -} -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li.active a, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li.active a, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li.active a, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li.active a, -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li.active button, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li.active button, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li.active button, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li.active button { - 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) .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) .repeat-days 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) .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 { - color: #fff !important; -} -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li button.active, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li button.active, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li button.active, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li button.active { - -webkit-box-shadow: inset 0 0 0 1px #71b05b !important; - box-shadow: inset 0 0 0 1px #71b05b !important; - background-color: #cce3c4 !important; -} -.task-column:not(.rewards) .color-good:not(.completed) .priority-multiplier li button:focus, -.task-column:not(.rewards) .color-good:not(.completed) .task-attributes li button:focus, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-days li button:focus, -.task-column:not(.rewards) .color-good:not(.completed) .repeat-frequency li button:focus { - border: 1px solid #8bbf79; - outline: 0; -} -.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 { - -webkit-box-shadow: inset 0 0 0 1px #71b05b !important; - box-shadow: inset 0 0 0 1px #71b05b !important; - background-color: #cce3c4 !important; -} -.task-column:not(.rewards) .color-good:not(.completed) .save-close > a, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > a, -.task-column:not(.rewards) .color-good:not(.completed) .save-close > button, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > button { - background-color: #cce3c4 !important; -} -.task-column:not(.rewards) .color-good:not(.completed) .save-close > a:active, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > a:active, -.task-column:not(.rewards) .color-good:not(.completed) .save-close > button:active, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > button:active { - background-color: #446f35 !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-good:not(.completed) .save-close > a:hover, - .task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > a:hover, - .task-column:not(.rewards) .color-good:not(.completed) .save-close > button:hover, - .task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > button:hover { - background-color: #c6e0bd !important; - } -} -.task-column:not(.rewards) .color-good:not(.completed) .save-close > a, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > a, -.task-column:not(.rewards) .color-good:not(.completed) .save-close > button, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > button, -.task-column:not(.rewards) .color-good:not(.completed) .save-close > input, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > input, -.task-column:not(.rewards) .color-good:not(.completed) .save-close textarea, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li textarea { - color: #355629 !important; - border-color: #a4cd96 !important; -} -.task-column:not(.rewards) .color-good:not(.completed) .save-close > input:hover, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > input:hover, -.task-column:not(.rewards) .color-good:not(.completed) .save-close textarea:hover, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li textarea:hover { - border-color: #7bb666 !important; -} -.task-column:not(.rewards) .color-good:not(.completed) .save-close > input:focus, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > input:focus, -.task-column:not(.rewards) .color-good:not(.completed) .save-close textarea:focus, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li textarea:focus { - border-color: #436e34 !important; - 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) .save-close 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) .save-close 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), -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > a:nth-of-type(2) { - border-left: 1px solid #c3deb9 !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-good:not(.completed) .save-close > div, - .task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > div { - border: 1px solid #a4cd96; - } -} -.task-column:not(.rewards) .color-good:not(.completed) .save-close > div h4, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > div h4 { - color: #436e34; - background-color: #cce3c4; - 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) .save-close > 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; -} -.task-column:not(.rewards) .color-good:not(.completed) .save-close > div h4:before, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > div h4:before { - background-color: #cce3c4; - border-color: #a4cd96; -} -.task-column:not(.rewards) .color-good:not(.completed) .save-close > div ul, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > div ul { - border-color: #cce3c4; -} -.task-column:not(.rewards) .color-good:not(.completed) .save-close > div a, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > div a, -.task-column:not(.rewards) .color-good:not(.completed) .save-close > div a:link, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > div a:link, -.task-column:not(.rewards) .color-good:not(.completed) .save-close > div a:visited, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li > div a:visited { - color: #355629 !important; -} -.task-column:not(.rewards) .color-good:not(.completed) .save-close .glyphicon, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li .glyphicon { - color: #467336; -} -.task-column:not(.rewards) .color-good:not(.completed) .save-close .tooltip, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li .tooltip { - border: 0; -} -.task-column:not(.rewards) .color-good:not(.completed) .save-close.active a, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li.active a, -.task-column:not(.rewards) .color-good:not(.completed) .save-close.active button, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li.active button { - background-color: #c3deb9 !important; - 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) .save-close.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) .save-close.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, -.task-column:not(.rewards) .color-good:not(.completed) .task-checklist-edit li button:focus { - border: 1px solid #8bbf79; - outline: 0; -} -.task-column:not(.rewards) .color-good:not(.completed) .task-actions { - background-color: #8bbf79; -} -.task-column:not(.rewards) .color-good:not(.completed) .action-yesno label, -.task-column:not(.rewards) .color-good:not(.completed) .task-action-btn, -.task-column:not(.rewards) .color-good:not(.completed) .task-actions a { - background-color: #8bbf79; -} -.task-column:not(.rewards) .color-good:not(.completed) .action-yesno label:hover, -.task-column:not(.rewards) .color-good:not(.completed) .task-action-btn:hover, -.task-column:not(.rewards) .color-good:not(.completed) .task-actions a:hover, -.task-column:not(.rewards) .color-good:not(.completed) .action-yesno label:focus, -.task-column:not(.rewards) .color-good:not(.completed) .task-action-btn:focus, -.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.habit:focus + a { - background-color: #71b05b; -} -.task-column:not(.rewards) .color-good:not(.completed) .task-actions a:nth-of-type(2) { - border-top: 1px solid #5c9748; -} -.task-column:not(.rewards) .color-good:not(.completed) .task-options { - background-color: #d9ead3; -} -.task-column:not(.rewards) .color-good:not(.completed) .option-group:not(.task-checklist) { - border-bottom: 1px solid #b2d4a6; -} -.task-column:not(.rewards) .color-good:not(.completed) .option-content { - border-color: #afd3a2 !important; -} -.task-column:not(.rewards) .color-good:not(.completed) .option-content:hover { - border-color: #84bb70 !important; -} -.task-column:not(.rewards) .color-good:not(.completed) .option-content:focus { - border-color: #477337 !important; - outline: none; -} -.task-column:not(.rewards) .color-better:not(.completed) { - background-color: #d0e0e3; - border: 1px solid #bbc9cc; -} -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > a, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > a, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > a, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > a, -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > button, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > button, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > button, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > button { - background-color: #d0e0e3 !important; -} -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > a:active, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > a:active, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > a:active, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > a:active, -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > button:active, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > button:active, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > button:active, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > button:active { - background-color: #3f636a !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > a:hover, - .task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > a:hover, - .task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > a:hover, - .task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > a:hover, - .task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > button:hover, - .task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > button:hover, - .task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > button:hover, - .task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > button:hover { - background-color: #cadcdf !important; - } -} -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > a, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > a, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > a, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > a, -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > button, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > button, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > button, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > button, -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > input, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > input, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > input, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > input, -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li textarea, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li textarea, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li textarea, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li textarea { - color: #314d52 !important; - border-color: #a4c3c9 !important; -} -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > input:hover, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > input:hover, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > input:hover, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > input:hover, -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li textarea:hover, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li textarea:hover, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li textarea:hover, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li textarea:hover { - border-color: #77a5ae !important; -} -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > input:focus, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > input:focus, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > input:focus, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > input:focus, -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li textarea:focus, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li textarea:focus, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li textarea:focus, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li textarea:focus { - 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) .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-days 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) .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-days 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), -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > a:nth-of-type(2), -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > a:nth-of-type(2), -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > a:nth-of-type(2) { - border-left: 1px solid #c6d9dd !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > div, - .task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > div, - .task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > div, - .task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > div { - border: 1px solid #a4c3c9; - } -} -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > div h4, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > div h4, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > div h4, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > div h4 { - color: #3e6168; - 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 { - background-color: #fff; - border-color: #a4c3c9; -} -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > div h4:before, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > div h4:before, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > div h4:before, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > div h4:before { - background-color: #d0e0e3; - border-color: #a4c3c9; -} -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > div ul, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > div ul, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > div ul, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > div ul { - border-color: #d0e0e3; -} -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > div a, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > div a, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > div a, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > div a, -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > div a:link, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > div a:link, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > div a:link, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > div a:link, -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li > div a:visited, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li > div a:visited, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li > div a:visited, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li > div a:visited { - color: #314d52 !important; -} -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li .glyphicon, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li .glyphicon, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li .glyphicon, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li .glyphicon { - color: #41666d; -} -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li .tooltip, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li .tooltip, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li .tooltip, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li .tooltip { - border: 0; -} -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li.active a, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li.active a, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li.active a, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li.active a, -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li.active button, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li.active button, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li.active button, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li.active button { - 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) .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) .repeat-days 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) .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 { - color: #fff !important; -} -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li button.active, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li button.active, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li button.active, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li button.active { - -webkit-box-shadow: inset 0 0 0 1px #6398a2 !important; - box-shadow: inset 0 0 0 1px #6398a2 !important; - background-color: #c2d7db !important; -} -.task-column:not(.rewards) .color-better:not(.completed) .priority-multiplier li button:focus, -.task-column:not(.rewards) .color-better:not(.completed) .task-attributes li button:focus, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-days li button:focus, -.task-column:not(.rewards) .color-better:not(.completed) .repeat-frequency li button:focus { - border: 1px solid #7eaab2; - outline: 0; -} -.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 { - -webkit-box-shadow: inset 0 0 0 1px #6398a2 !important; - box-shadow: inset 0 0 0 1px #6398a2 !important; - background-color: #c2d7db !important; -} -.task-column:not(.rewards) .color-better:not(.completed) .save-close > a, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > a, -.task-column:not(.rewards) .color-better:not(.completed) .save-close > button, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > button { - background-color: #c2d7db !important; -} -.task-column:not(.rewards) .color-better:not(.completed) .save-close > a:active, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > a:active, -.task-column:not(.rewards) .color-better:not(.completed) .save-close > button:active, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > button:active { - background-color: #3c5e65 !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-better:not(.completed) .save-close > a:hover, - .task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > a:hover, - .task-column:not(.rewards) .color-better:not(.completed) .save-close > button:hover, - .task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > button:hover { - background-color: #bcd3d7 !important; - } -} -.task-column:not(.rewards) .color-better:not(.completed) .save-close > a, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > a, -.task-column:not(.rewards) .color-better:not(.completed) .save-close > button, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > button, -.task-column:not(.rewards) .color-better:not(.completed) .save-close > input, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > input, -.task-column:not(.rewards) .color-better:not(.completed) .save-close textarea, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li textarea { - color: #2e494e !important; - border-color: #98bbc2 !important; -} -.task-column:not(.rewards) .color-better:not(.completed) .save-close > input:hover, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > input:hover, -.task-column:not(.rewards) .color-better:not(.completed) .save-close textarea:hover, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li textarea:hover { - border-color: #6d9fa9 !important; -} -.task-column:not(.rewards) .color-better:not(.completed) .save-close > input:focus, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > input:focus, -.task-column:not(.rewards) .color-better:not(.completed) .save-close textarea:focus, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li textarea:focus { - border-color: #3b5d63 !important; - 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) .save-close 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) .save-close 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) { - border-left: 1px solid #b8d0d5 !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-better:not(.completed) .save-close > div, - .task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > div { - border: 1px solid #98bbc2; - } -} -.task-column:not(.rewards) .color-better:not(.completed) .save-close > div h4, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > div h4 { - color: #3b5d63; - background-color: #c2d7db; - 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) .save-close > 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; -} -.task-column:not(.rewards) .color-better:not(.completed) .save-close > div h4:before, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > div h4:before { - background-color: #c2d7db; - border-color: #98bbc2; -} -.task-column:not(.rewards) .color-better:not(.completed) .save-close > div ul, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > div ul { - border-color: #c2d7db; -} -.task-column:not(.rewards) .color-better:not(.completed) .save-close > div a, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > div a, -.task-column:not(.rewards) .color-better:not(.completed) .save-close > div a:link, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > div a:link, -.task-column:not(.rewards) .color-better:not(.completed) .save-close > div a:visited, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li > div a:visited { - color: #2e494e !important; -} -.task-column:not(.rewards) .color-better:not(.completed) .save-close .glyphicon, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li .glyphicon { - color: #3d6168; -} -.task-column:not(.rewards) .color-better:not(.completed) .save-close .tooltip, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li .tooltip { - border: 0; -} -.task-column:not(.rewards) .color-better:not(.completed) .save-close.active a, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li.active a, -.task-column:not(.rewards) .color-better:not(.completed) .save-close.active button, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li.active button { - background-color: #b8d0d5 !important; - 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) .save-close.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 { - color: #fff !important; -} -.task-column:not(.rewards) .color-better:not(.completed) .save-close button:focus, -.task-column:not(.rewards) .color-better:not(.completed) .task-checklist-edit li button:focus { - border: 1px solid #7eaab2; - outline: 0; -} -.task-column:not(.rewards) .color-better:not(.completed) .task-actions { - background-color: #7eaab2; -} -.task-column:not(.rewards) .color-better:not(.completed) .action-yesno label, -.task-column:not(.rewards) .color-better:not(.completed) .task-action-btn, -.task-column:not(.rewards) .color-better:not(.completed) .task-actions a { - background-color: #7eaab2; -} -.task-column:not(.rewards) .color-better:not(.completed) .action-yesno label:hover, -.task-column:not(.rewards) .color-better:not(.completed) .task-action-btn:hover, -.task-column:not(.rewards) .color-better:not(.completed) .task-actions a:hover, -.task-column:not(.rewards) .color-better:not(.completed) .action-yesno label:focus, -.task-column:not(.rewards) .color-better:not(.completed) .task-action-btn:focus, -.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.habit:focus + a { - background-color: #6398a2; -} -.task-column:not(.rewards) .color-better:not(.completed) .task-actions a:nth-of-type(2) { - border-top: 1px solid #518088; -} -.task-column:not(.rewards) .color-better:not(.completed) .task-options { - background-color: #d0e0e3; -} -.task-column:not(.rewards) .color-better:not(.completed) .option-group:not(.task-checklist) { - border-bottom: 1px solid #a7c5cb; -} -.task-column:not(.rewards) .color-better:not(.completed) .option-content { - border-color: #a4c3c9 !important; -} -.task-column:not(.rewards) .color-better:not(.completed) .option-content:hover { - border-color: #77a5ae !important; -} -.task-column:not(.rewards) .color-better:not(.completed) .option-content:focus { - border-color: #3e6168 !important; - outline: none; -} -.task-column:not(.rewards) .color-best:not(.completed) { - background-color: #c9daf8; - border: 1px solid #b4c4df; -} -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > a, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > a, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > a, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > a, -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > button, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > button, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > button, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > button { - background-color: #c9daf8 !important; -} -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > a:active, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > a:active, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > a:active, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > a:active, -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > button:active, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > button:active, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > button:active, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > button:active { - background-color: #14459a !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > a:hover, - .task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > a:hover, - .task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > a:hover, - .task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > a:hover, - .task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > button:hover, - .task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > button:hover, - .task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > button:hover, - .task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > button:hover { - background-color: #c0d4f7 !important; - } -} -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > a, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > a, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > a, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > a, -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > button, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > button, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > button, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > button, -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > input, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > input, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > input, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > input, -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li textarea, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li textarea, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li textarea, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li textarea { - color: #0f3577 !important; - border-color: #89aef0 !important; -} -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > input:hover, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > input:hover, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > input:hover, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > input:hover, -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li textarea:hover, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li textarea:hover, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li textarea:hover, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li textarea:hover { - border-color: #4781e7 !important; -} -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > input:focus, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > input:focus, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > input:focus, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > input:focus, -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li textarea:focus, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li textarea:focus, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li textarea:focus, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li textarea:focus { - 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) .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) .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 { - 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) .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) .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 { - background-color: #89aef0 !important; -} -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > a:nth-of-type(2), -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > a:nth-of-type(2), -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > a:nth-of-type(2), -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > a:nth-of-type(2) { - border-left: 1px solid #bad0f6 !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > div, - .task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > div, - .task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > div, - .task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > div { - border: 1px solid #89aef0; - } -} -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > div h4, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > div h4, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > div h4, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > div h4 { - color: #144398; - 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 { - background-color: #fff; - border-color: #89aef0; -} -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > div h4:before, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > div h4:before, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > div h4:before, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > div h4:before { - background-color: #c9daf8; - border-color: #89aef0; -} -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > div ul, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > div ul, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > div ul, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > div ul { - border-color: #c9daf8; -} -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > div a, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > div a, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > div a, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > div a, -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > div a:link, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > div a:link, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > div a:link, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > div a:link, -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li > div a:visited, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li > div a:visited, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li > div a:visited, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li > div a:visited { - color: #0f3577 !important; -} -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li .glyphicon, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li .glyphicon, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li .glyphicon, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li .glyphicon { - color: #15479f; -} -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li .tooltip, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li .tooltip, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li .tooltip, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li .tooltip { - border: 0; -} -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li.active a, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li.active a, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li.active a, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li.active a, -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li.active button, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li.active button, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li.active button, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li.active button { - 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) .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) .repeat-days 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) .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 { - color: #fff !important; -} -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li button.active, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li button.active, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li button.active, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li button.active { - -webkit-box-shadow: inset 0 0 0 1px #2a6de3 !important; - box-shadow: inset 0 0 0 1px #2a6de3 !important; - background-color: #b5ccf5 !important; -} -.task-column:not(.rewards) .color-best:not(.completed) .priority-multiplier li button:focus, -.task-column:not(.rewards) .color-best:not(.completed) .task-attributes li button:focus, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-days li button:focus, -.task-column:not(.rewards) .color-best:not(.completed) .repeat-frequency li button:focus { - border: 1px solid #5288e9; - outline: 0; -} -.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 { - -webkit-box-shadow: inset 0 0 0 1px #2a6de3 !important; - box-shadow: inset 0 0 0 1px #2a6de3 !important; - background-color: #b5ccf5 !important; -} -.task-column:not(.rewards) .color-best:not(.completed) .save-close > a, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > a, -.task-column:not(.rewards) .color-best:not(.completed) .save-close > button, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > button { - background-color: #b5ccf5 !important; -} -.task-column:not(.rewards) .color-best:not(.completed) .save-close > a:active, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > a:active, -.task-column:not(.rewards) .color-best:not(.completed) .save-close > button:active, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > button:active { - background-color: #144192 !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-best:not(.completed) .save-close > a:hover, - .task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > a:hover, - .task-column:not(.rewards) .color-best:not(.completed) .save-close > button:hover, - .task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > button:hover { - background-color: #acc6f4 !important; - } -} -.task-column:not(.rewards) .color-best:not(.completed) .save-close > a, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > a, -.task-column:not(.rewards) .color-best:not(.completed) .save-close > button, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > button, -.task-column:not(.rewards) .color-best:not(.completed) .save-close > input, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > input, -.task-column:not(.rewards) .color-best:not(.completed) .save-close textarea, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li textarea { - color: #0f3271 !important; - border-color: #78a2ed !important; -} -.task-column:not(.rewards) .color-best:not(.completed) .save-close > input:hover, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > input:hover, -.task-column:not(.rewards) .color-best:not(.completed) .save-close textarea:hover, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li textarea:hover { - border-color: #3a77e4 !important; -} -.task-column:not(.rewards) .color-best:not(.completed) .save-close > input:focus, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > input:focus, -.task-column:not(.rewards) .color-best:not(.completed) .save-close textarea:focus, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li textarea:focus { - border-color: #13408f !important; - 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) .save-close 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) .save-close 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), -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > a:nth-of-type(2) { - border-left: 1px solid #a7c2f3 !important; -} -@media screen and (min-width: 768px) { - .task-column:not(.rewards) .color-best:not(.completed) .save-close > div, - .task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > div { - border: 1px solid #78a2ed; - } -} -.task-column:not(.rewards) .color-best:not(.completed) .save-close > div h4, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > div h4 { - color: #13408f; - background-color: #b5ccf5; - 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) .save-close > 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; -} -.task-column:not(.rewards) .color-best:not(.completed) .save-close > div h4:before, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > div h4:before { - background-color: #b5ccf5; - border-color: #78a2ed; -} -.task-column:not(.rewards) .color-best:not(.completed) .save-close > div ul, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > div ul { - border-color: #b5ccf5; -} -.task-column:not(.rewards) .color-best:not(.completed) .save-close > div a, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > div a, -.task-column:not(.rewards) .color-best:not(.completed) .save-close > div a:link, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > div a:link, -.task-column:not(.rewards) .color-best:not(.completed) .save-close > div a:visited, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li > div a:visited { - color: #0f3271 !important; -} -.task-column:not(.rewards) .color-best:not(.completed) .save-close .glyphicon, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li .glyphicon { - color: #144396; -} -.task-column:not(.rewards) .color-best:not(.completed) .save-close .tooltip, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li .tooltip { - border: 0; -} -.task-column:not(.rewards) .color-best:not(.completed) .save-close.active a, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li.active a, -.task-column:not(.rewards) .color-best:not(.completed) .save-close.active button, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li.active button { - background-color: #a7c2f3 !important; - 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) .save-close.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) .save-close.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, -.task-column:not(.rewards) .color-best:not(.completed) .task-checklist-edit li button:focus { - border: 1px solid #5288e9; - outline: 0; -} -.task-column:not(.rewards) .color-best:not(.completed) .task-actions { - background-color: #5288e9; -} -.task-column:not(.rewards) .color-best:not(.completed) .action-yesno label, -.task-column:not(.rewards) .color-best:not(.completed) .task-action-btn, -.task-column:not(.rewards) .color-best:not(.completed) .task-actions a { - background-color: #5288e9; -} -.task-column:not(.rewards) .color-best:not(.completed) .action-yesno label:hover, -.task-column:not(.rewards) .color-best:not(.completed) .task-action-btn:hover, -.task-column:not(.rewards) .color-best:not(.completed) .task-actions a:hover, -.task-column:not(.rewards) .color-best:not(.completed) .action-yesno label:focus, -.task-column:not(.rewards) .color-best:not(.completed) .task-action-btn:focus, -.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.habit:focus + a { - background-color: #2a6de3; -} -.task-column:not(.rewards) .color-best:not(.completed) .task-actions a:nth-of-type(2) { - border-top: 1px solid #1a58c7; -} -.task-column:not(.rewards) .color-best:not(.completed) .task-options { - background-color: #c9daf8; -} -.task-column:not(.rewards) .color-best:not(.completed) .option-group:not(.task-checklist) { - border-bottom: 1px solid #8db1f0; -} -.task-column:not(.rewards) .color-best:not(.completed) .option-content { - border-color: #89aef0 !important; -} -.task-column:not(.rewards) .color-best:not(.completed) .option-content:hover { - border-color: #4781e7 !important; -} -.task-column:not(.rewards) .color-best:not(.completed) .option-content:focus { - border-color: #144398 !important; - outline: none; -} -.completed { - color: #989898; - background-color: #d9d9d9; - border: 1px solid #c3c3c3; -} -.completed .task-text .habitica-emoji { - opacity: 0.39; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=39)"; - filter: alpha(opacity=39); -} -.completed .priority-multiplier li > a, -.completed .task-attributes li > a, -.completed .repeat-days li > a, -.completed .repeat-frequency li > a, -.completed .priority-multiplier li > button, -.completed .task-attributes li > button, -.completed .repeat-days li > button, -.completed .repeat-frequency li > button { - background-color: #d9d9d9 !important; -} -.completed .priority-multiplier li > a:active, -.completed .task-attributes li > a:active, -.completed .repeat-days li > a:active, -.completed .repeat-frequency li > a:active, -.completed .priority-multiplier li > button:active, -.completed .task-attributes li > button:active, -.completed .repeat-days li > button:active, -.completed .repeat-frequency li > button:active { - background-color: #545454 !important; -} -@media screen and (min-width: 768px) { - .completed .priority-multiplier li > a:hover, - .completed .task-attributes li > a:hover, - .completed .repeat-days li > a:hover, - .completed .repeat-frequency li > a:hover, - .completed .priority-multiplier li > button:hover, - .completed .task-attributes li > button:hover, - .completed .repeat-days li > button:hover, - .completed .repeat-frequency li > button:hover { - background-color: #d4d4d4 !important; - } -} -.completed .priority-multiplier li > a, -.completed .task-attributes li > a, -.completed .repeat-days li > a, -.completed .repeat-frequency li > a, -.completed .priority-multiplier li > button, -.completed .task-attributes li > button, -.completed .repeat-days li > button, -.completed .repeat-frequency li > button, -.completed .priority-multiplier li > input, -.completed .task-attributes li > input, -.completed .repeat-days li > input, -.completed .repeat-frequency li > input, -.completed .priority-multiplier li textarea, -.completed .task-attributes li textarea, -.completed .repeat-days li textarea, -.completed .repeat-frequency li textarea { - color: #414141 !important; - border-color: #b6b6b6 !important; -} -.completed .priority-multiplier li > input:hover, -.completed .task-attributes li > input:hover, -.completed .repeat-days li > input:hover, -.completed .repeat-frequency li > input:hover, -.completed .priority-multiplier li textarea:hover, -.completed .task-attributes li textarea:hover, -.completed .repeat-days li textarea:hover, -.completed .repeat-frequency li textarea:hover { - border-color: #929292 !important; -} -.completed .priority-multiplier li > input:focus, -.completed .task-attributes li > input:focus, -.completed .repeat-days li > input:focus, -.completed .repeat-frequency li > input:focus, -.completed .priority-multiplier li textarea:focus, -.completed .task-attributes li textarea:focus, -.completed .repeat-days li textarea:focus, -.completed .repeat-frequency li textarea:focus { - border-color: #535353 !important; - outline: none; -} -.completed .priority-multiplier li > input + button:focus, -.completed .task-attributes li > input + button:focus, -.completed .repeat-days li > input + button:focus, -.completed .repeat-frequency li > input + button:focus, -.completed .priority-multiplier li textarea + button:focus, -.completed .task-attributes li textarea + button:focus, -.completed .repeat-days li textarea + button:focus, -.completed .repeat-frequency li textarea + button:focus { - border-color: #929292 !important; - background-color: #ccc !important; - outline: none; -} -.completed .priority-multiplier li > input + button:active, -.completed .task-attributes li > input + button:active, -.completed .repeat-days li > input + button:active, -.completed .repeat-frequency li > input + button:active, -.completed .priority-multiplier li textarea + button:active, -.completed .task-attributes li textarea + button:active, -.completed .repeat-days li textarea + button:active, -.completed .repeat-frequency li textarea + button:active { - background-color: #b6b6b6 !important; -} -.completed .priority-multiplier li > a:nth-of-type(2), -.completed .task-attributes li > a:nth-of-type(2), -.completed .repeat-days li > a:nth-of-type(2), -.completed .repeat-frequency li > a:nth-of-type(2) { - border-left: 1px solid #d1d1d1 !important; -} -@media screen and (min-width: 768px) { - .completed .priority-multiplier li > div, - .completed .task-attributes li > div, - .completed .repeat-days li > div, - .completed .repeat-frequency li > div { - border: 1px solid #b6b6b6; - } -} -.completed .priority-multiplier li > div h4, -.completed .task-attributes li > div h4, -.completed .repeat-days li > div h4, -.completed .repeat-frequency li > div h4 { - color: #535353; - background-color: #d9d9d9; - border-color: #b6b6b6; -} -.completed .priority-multiplier li > div > ul:first-child:before, -.completed .task-attributes li > div > ul:first-child:before, -.completed .repeat-days li > div > ul:first-child:before, -.completed .repeat-frequency li > div > ul:first-child:before, -.completed .priority-multiplier li > div > div:first-child:before, -.completed .task-attributes li > div > div:first-child:before, -.completed .repeat-days li > div > div:first-child:before, -.completed .repeat-frequency li > div > div:first-child:before { - background-color: #fff; - border-color: #b6b6b6; -} -.completed .priority-multiplier li > div h4:before, -.completed .task-attributes li > div h4:before, -.completed .repeat-days li > div h4:before, -.completed .repeat-frequency li > div h4:before { - background-color: #d9d9d9; - border-color: #b6b6b6; -} -.completed .priority-multiplier li > div ul, -.completed .task-attributes li > div ul, -.completed .repeat-days li > div ul, -.completed .repeat-frequency li > div ul { - border-color: #d9d9d9; -} -.completed .priority-multiplier li > div a, -.completed .task-attributes li > div a, -.completed .repeat-days li > div a, -.completed .repeat-frequency li > div a, -.completed .priority-multiplier li > div a:link, -.completed .task-attributes li > div a:link, -.completed .repeat-days li > div a:link, -.completed .repeat-frequency li > div a:link, -.completed .priority-multiplier li > div a:visited, -.completed .task-attributes li > div a:visited, -.completed .repeat-days li > div a:visited, -.completed .repeat-frequency li > div a:visited { - color: #414141 !important; -} -.completed .priority-multiplier li .glyphicon, -.completed .task-attributes li .glyphicon, -.completed .repeat-days li .glyphicon, -.completed .repeat-frequency li .glyphicon { - color: #575757; -} -.completed .priority-multiplier li .tooltip, -.completed .task-attributes li .tooltip, -.completed .repeat-days li .tooltip, -.completed .repeat-frequency li .tooltip { - border: 0; -} -.completed .priority-multiplier li.active a, -.completed .task-attributes li.active a, -.completed .repeat-days li.active a, -.completed .repeat-frequency li.active a, -.completed .priority-multiplier li.active button, -.completed .task-attributes li.active button, -.completed .repeat-days li.active button, -.completed .repeat-frequency li.active button { - background-color: #d1d1d1 !important; - border-color: #6d6d6d !important; -} -.completed .priority-multiplier li.active.filters-tags a, -.completed .task-attributes li.active.filters-tags a, -.completed .repeat-days li.active.filters-tags a, -.completed .repeat-frequency li.active.filters-tags a, -.completed .priority-multiplier li.active.filters-tags button, -.completed .task-attributes li.active.filters-tags button, -.completed .repeat-days li.active.filters-tags button, -.completed .repeat-frequency li.active.filters-tags button { - background-color: #929292 !important; - border-color: #d9d9d9 !important; - color: #fff !important; -} -.completed .priority-multiplier li.active.filters-tags a span, -.completed .task-attributes li.active.filters-tags a span, -.completed .repeat-days li.active.filters-tags a span, -.completed .repeat-frequency li.active.filters-tags a span, -.completed .priority-multiplier li.active.filters-tags button span, -.completed .task-attributes li.active.filters-tags button span, -.completed .repeat-days li.active.filters-tags button span, -.completed .repeat-frequency li.active.filters-tags button span { - color: #fff !important; -} -.completed .priority-multiplier li button.active, -.completed .task-attributes li button.active, -.completed .repeat-days li button.active, -.completed .repeat-frequency li button.active { - -webkit-box-shadow: inset 0 0 0 1px #828282 !important; - box-shadow: inset 0 0 0 1px #828282 !important; - background-color: #cecece !important; -} -.completed .priority-multiplier li button:focus, -.completed .task-attributes li button:focus, -.completed .repeat-days li button:focus, -.completed .repeat-frequency li button:focus { - border: 1px solid #989898; - outline: 0; -} -.completed .plusminus .task-checker label:after { - border: 1px solid #989898 !important; -} -.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; -} -.completed .save-close > a, -.completed .task-checklist-edit li > a, -.completed .save-close > button, -.completed .task-checklist-edit li > button { - background-color: #cecece !important; -} -.completed .save-close > a:active, -.completed .task-checklist-edit li > a:active, -.completed .save-close > button:active, -.completed .task-checklist-edit li > button:active { - background-color: #505050 !important; -} -@media screen and (min-width: 768px) { - .completed .save-close > a:hover, - .completed .task-checklist-edit li > a:hover, - .completed .save-close > button:hover, - .completed .task-checklist-edit li > button:hover { - background-color: #c9c9c9 !important; - } -} -.completed .save-close > a, -.completed .task-checklist-edit li > a, -.completed .save-close > button, -.completed .task-checklist-edit li > button, -.completed .save-close > input, -.completed .task-checklist-edit li > input, -.completed .save-close textarea, -.completed .task-checklist-edit li textarea { - color: #3e3e3e !important; - border-color: #adadad !important; -} -.completed .save-close > input:hover, -.completed .task-checklist-edit li > input:hover, -.completed .save-close textarea:hover, -.completed .task-checklist-edit li textarea:hover { - border-color: #8a8a8a !important; -} -.completed .save-close > input:focus, -.completed .task-checklist-edit li > input:focus, -.completed .save-close textarea:focus, -.completed .task-checklist-edit li textarea:focus { - border-color: #4f4f4f !important; - outline: none; -} -.completed .save-close > input + button:focus, -.completed .task-checklist-edit li > input + button:focus, -.completed .save-close textarea + button:focus, -.completed .task-checklist-edit li textarea + button:focus { - border-color: #8a8a8a !important; - background-color: #c1c1c1 !important; - outline: none; -} -.completed .save-close > input + button:active, -.completed .task-checklist-edit li > input + button:active, -.completed .save-close textarea + button:active, -.completed .task-checklist-edit li textarea + button:active { - background-color: #adadad !important; -} -.completed .save-close > a:nth-of-type(2), -.completed .task-checklist-edit li > a:nth-of-type(2) { - border-left: 1px solid #c6c6c6 !important; -} -@media screen and (min-width: 768px) { - .completed .save-close > div, - .completed .task-checklist-edit li > div { - border: 1px solid #adadad; - } -} -.completed .save-close > div h4, -.completed .task-checklist-edit li > div h4 { - color: #4f4f4f; - background-color: #cecece; - border-color: #adadad; -} -.completed .save-close > div > ul:first-child:before, -.completed .task-checklist-edit li > div > ul:first-child:before, -.completed .save-close > div > div:first-child:before, -.completed .task-checklist-edit li > div > div:first-child:before { - background-color: #fff; - border-color: #adadad; -} -.completed .save-close > div h4:before, -.completed .task-checklist-edit li > div h4:before { - background-color: #cecece; - border-color: #adadad; -} -.completed .save-close > div ul, -.completed .task-checklist-edit li > div ul { - border-color: #cecece; -} -.completed .save-close > div a, -.completed .task-checklist-edit li > div a, -.completed .save-close > div a:link, -.completed .task-checklist-edit li > div a:link, -.completed .save-close > div a:visited, -.completed .task-checklist-edit li > div a:visited { - color: #3e3e3e !important; -} -.completed .save-close .glyphicon, -.completed .task-checklist-edit li .glyphicon { - color: #525252; -} -.completed .save-close .tooltip, -.completed .task-checklist-edit li .tooltip { - border: 0; -} -.completed .save-close.active a, -.completed .task-checklist-edit li.active a, -.completed .save-close.active button, -.completed .task-checklist-edit li.active button { - background-color: #c6c6c6 !important; - border-color: #676767 !important; -} -.completed .save-close.active.filters-tags a, -.completed .task-checklist-edit li.active.filters-tags a, -.completed .save-close.active.filters-tags button, -.completed .task-checklist-edit li.active.filters-tags button { - background-color: #8a8a8a !important; - border-color: #cecece !important; - color: #fff !important; -} -.completed .save-close.active.filters-tags a span, -.completed .task-checklist-edit li.active.filters-tags a span, -.completed .save-close.active.filters-tags button span, -.completed .task-checklist-edit li.active.filters-tags button span { - color: #fff !important; -} -.completed .save-close button:focus, -.completed .task-checklist-edit li button:focus { - border: 1px solid #989898; - outline: 0; -} -.completed .action-yesno label, -.completed .task-action-btn { - background-color: #989898; -} -.completed .action-yesno label:hover, -.completed .task-action-btn:hover, -.completed .action-yesno label:focus, -.completed .task-action-btn:focus { - background-color: #828282; -} -.completed input[type=checkbox]:focus + label { - background-color: #828282; -} -.completed .task-options { - background-color: #d9d9d9; -} -.completed .option-group:not(.task-checklist) { - border-bottom: 1px solid #b8b8b8; -} -.completed .option-content { - border-color: #b6b6b6 !important; -} -.completed .option-content:hover { - border-color: #929292 !important; -} -.completed .option-content:focus { - border-color: #535353 !important; - outline: 0; -} -.reward, -.reward-item { - background-color: #fff; - border: 1px solid #d9d9d9 !important; -} -.tasks-lists .row { - margin-left: -5px; - margin-right: -5px; -} -.tasks-lists .row .col-md-3 { - padding-left: 10px; - padding-right: 10px; - margin-bottom: 15px; -} -.task-column { - padding: 1.5em; - background: #f2f5f6; - border: 1px solid #c3d1d6; -} -.task-column:after { - clear: both; - display: block; - content: ""; -} -.task-column h2 { - color: #4c666e; -} -.task-column .task-column_title { - margin: 0 0 0.5em; - padding: 0; - font-weight: 300; -} -.task-column .option-box .glyphicon { - font-size: 1.5em; - vertical-align: middle; -} -.task-column.preview { - padding: 0; - background: transparent; - border: 0; -} -.task-column.preview .task:hover { - cursor: auto; -} -.task-add { - position: relative; - margin-top: 1.5em; -} -.task-add > a, -.task-add > button { - background-color: #f6f8f9 !important; -} -.task-add > a:active, -.task-add > button:active { - background-color: #4d6673 !important; -} -@media screen and (min-width: 768px) { - .task-add > a:hover, - .task-add > button:hover { - background-color: #eff3f4 !important; - } -} -.task-add > a, -.task-add > button, -.task-add > input, -.task-add textarea { - color: #3b4f59 !important; - border-color: #c6d3d9 !important; -} -.task-add > input:hover, -.task-add textarea:hover { - border-color: #95acb8 !important; -} -.task-add > input:focus, -.task-add textarea:focus { - border-color: #4c6571 !important; - outline: none; -} -.task-add > input + button:focus, -.task-add textarea + button:focus { - border-color: #95acb8 !important; - background-color: #e4eaed !important; - outline: none; -} -.task-add > input + button:active, -.task-add textarea + button:active { - background-color: #c6d3d9 !important; -} -.task-add > a:nth-of-type(2) { - border-left: 1px solid #ebeff1 !important; -} -@media screen and (min-width: 768px) { - .task-add > div { - border: 1px solid #c6d3d9; - } -} -.task-add > div h4 { - color: #4c6571; - background-color: #f6f8f9; - border-color: #c6d3d9; -} -.task-add > div > ul:first-child:before, -.task-add > div > div:first-child:before { - background-color: #fff; - border-color: #c6d3d9; -} -.task-add > div h4:before { - background-color: #f6f8f9; - border-color: #c6d3d9; -} -.task-add > div ul { - border-color: #f6f8f9; -} -.task-add > div a, -.task-add > div a:link, -.task-add > div a:visited { - color: #3b4f59 !important; -} -.task-add .glyphicon { - color: #4f6a77; -} -.task-add .tooltip { - border: 0; -} -.task-add.active a, -.task-add.active button { - background-color: #ebeff1 !important; - border-color: #638494 !important; -} -.task-add.active.filters-tags a, -.task-add.active.filters-tags button { - background-color: #95acb8 !important; - border-color: #f6f8f9 !important; - color: #fff !important; -} -.task-add.active.filters-tags a span, -.task-add.active.filters-tags button span { - color: #fff !important; -} -.task-add input, -.task-add button { - height: 2.618em; -} -.task-add input, -.task-add textarea { - width: 80%; -} -.task-add textarea { - float: none; - resize: vertical; - -webkit-border-radius: 4px 0 0 4px; - border-radius: 4px 0 0 4px; -} -.task-add textarea + button { - position: absolute; - top: 0; - bottom: 0; - right: 0; - height: 100%; -} -.task-add button { - width: 20%; -} -.task-add button span { - font-size: 0.8em; -} -.task-add .help-block a { - border: 0; - float: right; - font-size: 0.85em; -} -.dailiesRestingInInn { - clear: both; -} -.dailiesRestingInInn button { - margin-top: 10px; -} -.task { - list-style: none; - clear: both; - padding: 0; - min-height: 3em; - margin-bottom: 0.75em; - position: relative; -} -.task:hover { - cursor: move; -} -.task :last-child { - margin-bottom: 0; -} -.task label { - font-weight: 400; -} -.task input[type="text"], -.task input[type="number"], -.task textarea.option-content { - border: 1px solid #aaa; - -webkit-border-radius: 0.382em; - border-radius: 0.382em; - padding-left: 0.618em; - -webkit-appearance: none; - -moz-appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - -webkit-box-shadow: none; - box-shadow: none; -} -.task-text { - display: block; - padding: 0.75em 0 0.75em 3.75em; - line-height: 1.4; - word-wrap: break-word; -} -.task-text markdown > ul > li { - 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-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); -} -.task-controls { - display: inline-block; - float: left; - margin-left: -0.5em; - margin-right: 0.5em; -} -.task-controls.task-primary { - position: absolute; - top: 0; - bottom: 0; - left: 0; - float: none; - margin: 0; -} -.task-action-btn { - display: inline-block; - width: 2.12765em; - height: 2.12765em; - padding: 0; - font-size: 1.41em; - line-height: 2.12765; - text-align: center; - color: #222; - vertical-align: top; - border-right: 1px solid rgba(0,0,0,0.25); -} -.task-action-btn:last-child { - border: 0; -} -.task-action-btn:hover, -.task-action-btn:focus { - color: #222; - text-decoration: none; -} -.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)"; - filter: alpha(opacity=0); - width: 10px; -} -.task-checker label { - display: inline-block; - width: 3em; - height: 100%; - margin: 0; - vertical-align: top; - cursor: pointer; -} -.plusminus { - text-align: center; -} -.plusminus .task-checker { - font-size: 1.5em; - margin-bottom: 0.5em; -} -.plusminus .task-checker:first-of-type { - margin-right: 0.5em; -} -.plusminus .task-checker label:after { - width: 2em; - line-height: 1.5; -} -.plusminus .task-checker label[for$="plus"]:after { - content: 'ļ¼‹'; -} -.plusminus .task-checker label[for$="minus"]:after { - content: 'āˆ’'; -} -.action-yesno { - position: relative; -} -.action-yesno label:after, -.action-yesno label:before { - content: ''; - display: block; - height: 1.714285714em; - width: 1.714285714em; - font-size: 1.75em; - line-height: 1.714285714em; - text-align: center; - color: #000; - opacity: 0.2; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=20)"; - filter: alpha(opacity=20); -} -.action-yesno label:after { - height: 10px; - width: 10px; - margin: 15px auto; - border: 1px solid #222; -} -.action-yesno label:before { - position: absolute; - left: 0; -} -.action-yesno label:hover:before, -.action-yesno label:focus:before { - content: ''; -} -.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"; - border: none; - margin: 0; - line-height: 1.714285714em; - height: 1.714285714em; - width: 1.714285714em; - text-align: center; - opacity: 0.5 !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; - filter: alpha(opacity=75) !important; -} -.action-yesno input[type=checkbox]:checked + label:after { - content: "\E013"; - font-family: "Glyphicons Halflings"; - border: none; - margin: 0; - line-height: 1.714285714em; - height: 1.714285714em; - width: 1.714285714em; - text-align: center; - opacity: 0.75; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=75)"; - filter: alpha(opacity=75); -} -.task-meta-controls { - float: right; - margin: 0.75em 0.5em 0 0.5em; - height: 1em; - opacity: 0.25; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=25)"; - filter: alpha(opacity=25); -} -.task-meta-controls a { - text-decoration: none; -} -.task-meta-controls .glyphicon-signal { - margin-left: 0.25em; -} -.task-meta-controls .glyphicon-remove, -.task-meta-controls .glyphicon-ok, -.task-meta-controls a.badge { - margin-right: 0.25em; -} -.task-meta-controls .glyphicon-tags { - margin-left: 0.1em; - margin-right: 0.4em; -} -.task-meta-controls a.badge { - position: relative; - top: -2px; -} -.task:hover .task-meta-controls { - opacity: 1; - -ms-filter: none; - filter: none; -} -.popover { - line-height: 1.5; - margin-bottom: 10px; - margin-right: 5px; -} -form { - margin-bottom: 0; -} -.task textarea.form-control { - position: relative; - z-index: 50; -} -/*[class$="-options"] { - padding: 1em 1em 0; - color: #333; - position: relative; -}*/ -[class$="-options"] .option-group { - padding: 0 0 1em; - margin-bottom: 1em; - margin-top: 1em; -} -[class$="-options"] button.advanced-options-toggle { - display: block; - width: 100%; - background: none; -} -[class$="-options"] .option-title { - font-size: 1em; - margin: 0.5em 0 0.5em; - line-height: 1; - border: 0; - padding: 0; - font-weight: bold; - text-align: center; -} -[class$="-options"] .option-title.mega { - cursor: pointer; -} -[class$="-options"] .option-title.mega:after { - font-family: "Glyphicons Halflings"; - font-size: 0.75em; - content: "\E114"; - padding-left: 0.75em; -} -[class$="-options"] .option-title.mega.active:after { - content: "\E113"; -} -.option-content { - height: 2.5em; - width: 100%; - margin: 0 0 1em; - padding: 0 0 0 0.5em; -} -textarea.option-content { - height: 5em; - padding-top: 0.25em; - resize: none; - margin-bottom: 0; -} -.option-short .option-content { - width: 4em; - display: inline-block; -} -.option-short .input-suffix { - vertical-align: 20%; - padding-left: 0.25em; -} -.option-medium .option-content { - width: 6em; - display: inline-block; -} -.option-medium .input-suffix { - vertical-align: 20%; - padding-left: 0.25em; -} -.task-controls.tile-group { - display: block; - text-align: center; - margin: 0; -} -.task-controls.tile-group.task-attributes { - text-align: left; -} -.task-action-btn.tile { - border: 0; - font-size: 1.15em; - font-weight: 300; - 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)"; - filter: alpha(opacity=50); - width: auto; - padding: 0 0.5em; -} -.task-action-btn.tile.active { - opacity: 1; - -ms-filter: none; - filter: none; -} -.tile.solid { - opacity: 1; - -ms-filter: none; - filter: none; -} -.tile.spacious { - margin: 0.75em 0; - font-size: 1.25em; - opacity: 1; - -ms-filter: none; - filter: none; -} -.tile.bright { - background-color: #d4e1f9; -} -.tile.bright:hover, -.tile.bright:focus { - background-color: #dfe9fb; -} -.tile.flush { - margin-left: 0; - border: 1px solid rgba(0,0,0,0.2); - outline: 0; - line-height: 2em; -} -.tile.flush:first-child { - border-right: 0; -} -.task-options .task-action-btn.tile { - margin: 3px; -} -.task-options form { - padding-bottom: 1em; -} -.task-options .priority-multiplier, -.task-options .task-attributes, -.task-options .repeat-days, -.task-options .repeat-frequency { - text-align: center; -} -.task-options .priority-multiplier li, -.task-options .task-attributes li, -.task-options .repeat-days li, -.task-options .repeat-frequency li { - margin-bottom: 1em; - margin-right: 0.75em; - display: inline-block; -} -.task-options .priority-multiplier li:last-of-type, -.task-options .task-attributes li:last-of-type, -.task-options .repeat-days li:last-of-type, -.task-options .repeat-frequency li:last-of-type { - margin-right: 0; -} -.task-options .repeat-days { - padding-bottom: 1em; -} -.task-options .repeat-days li button { - min-width: 2.5em; -} -.task-options .save-close { - text-align: center; -} -.dailies .repeat-weekly { - padding-bottom: 1em; -} -.habits .task-actions { - width: 3em; - height: 100%; -} -.habits .task-actions a { - display: block; - width: 100%; - height: 100%; - position: relative; - font-size: 0.8em; -} -.habits .task-actions a span { - position: absolute; - margin: auto; - display: block; - height: 1em; - width: 1em; - top: 0; - left: 0; - bottom: 0; - right: 0; -} -.habits .task-actions a:hover, -.habits .task-actions a:focus { - color: #222; - text-decoration: none; -} -.habits .habit-wide { - min-height: 4.5em; -} -.habits .habit-wide .task-actions a { - height: 50%; -} -.habits .habit-narrow .task-actions { - display: none; -} -.habits .habit-narrow .task-text { - padding-left: 1em; -} -.task-checklist-edit > ul { - text-align: center; -} -.task-checklist-edit > ul li button { - margin-top: 0.5em; -} -.task-checklist-edit > ul li button span { - margin-right: 0.5em; -} -.task-checklist-edit > .checklist-form input { - width: 70%; -/* Add interaction cues on hover and focus */ -} -.task-checklist-edit > .checklist-form input:hover, -.task-checklist-edit > .checklist-form input:focus { - background-color: #ffd; - -webkit-transition: background-color 0.5s; - -moz-transition: background-color 0.5s; - -o-transition: background-color 0.5s; - -ms-transition: background-color 0.5s; - transition: background-color 0.5s; -} -.task-checklist-edit > .checklist-form .checklist-icon { - opacity: 0.25; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=25)"; - filter: alpha(opacity=25); - text-align: center; - line-height: 1.5; - float: left; - padding: 0.25em 0.5em; -} -.task-checklist-edit > .checklist-form li { - margin-bottom: 1em; -} -.task-checklist-edit > .checklist-form li:hover .checklist-icon { - opacity: 1; - -ms-filter: none; - filter: none; -} -.task-filter { - margin: 1.5em 0 1em 0; - border-color: #c3d1d6; - width: 100%; -} -.task-filter li { - border-right-color: #e0e7ea; -} -.task-filter li.active a { - -webkit-box-shadow: 0 0 0 1px #adc0c6 !important; - box-shadow: 0 0 0 1px #adc0c6 !important; -} -.task-filter li:first-of-type { - color: #648590; -} -.task-filter li { - width: 33.333%; - text-align: center; -} -.task-filter li > a, -.task-filter li > button { - background-color: #f2f5f6 !important; -} -.task-filter li > a:active, -.task-filter li > button:active { - background-color: #4d6770 !important; -} -@media screen and (min-width: 768px) { - .task-filter li > a:hover, - .task-filter li > button:hover { - background-color: #ebf0f1 !important; - } -} -.task-filter li > a, -.task-filter li > button, -.task-filter li > input, -.task-filter li textarea { - color: #3c5057 !important; - border-color: #c3d1d6 !important; -} -.task-filter li > input:hover, -.task-filter li textarea:hover { - border-color: #93acb5 !important; -} -.task-filter li > input:focus, -.task-filter li textarea:focus { - border-color: #4c666e !important; - outline: none; -} -.task-filter li > input + button:focus, -.task-filter li textarea + button:focus { - border-color: #93acb5 !important; - background-color: #e0e7ea !important; - outline: none; -} -.task-filter li > input + button:active, -.task-filter li textarea + button:active { - background-color: #c3d1d6 !important; -} -.task-filter li > a:nth-of-type(2) { - border-left: 1px solid #e7edee !important; -} -@media screen and (min-width: 768px) { - .task-filter li > div { - border: 1px solid #c3d1d6; - } -} -.task-filter li > div h4 { - color: #4c666e; - background-color: #f2f5f6; - border-color: #c3d1d6; -} -.task-filter li > div > ul:first-child:before, -.task-filter li > div > div:first-child:before { - background-color: #fff; - border-color: #c3d1d6; -} -.task-filter li > div h4:before { - background-color: #f2f5f6; - border-color: #c3d1d6; -} -.task-filter li > div ul { - border-color: #f2f5f6; -} -.task-filter li > div a, -.task-filter li > div a:link, -.task-filter li > div a:visited { - color: #3c5057 !important; -} -.task-filter li .glyphicon { - color: #506a73; -} -.task-filter li .tooltip { - border: 0; -} -.task-filter li.active a, -.task-filter li.active button { - background-color: #e7edee !important; - border-color: #648590 !important; -} -.task-filter li.active.filters-tags a, -.task-filter li.active.filters-tags button { - background-color: #93acb5 !important; - border-color: #f2f5f6 !important; - color: #fff !important; -} -.task-filter li.active.filters-tags a span, -.task-filter li.active.filters-tags button span { - color: #fff !important; -} -.task-filter li:nth-child(3n + 1):nth-last-child(2), -.task-filter li:nth-child(3n + 1):nth-last-child(2) + li { - width: 50%; -} -.task-filter li a { - width: 100%; - height: 100%; -} -.rewards .task { - min-height: 3.07em; -} -.rewards .task-filter li:nth-child(3n + 1):nth-last-child(2) { - width: 33.333%; -} -.rewards .task-filter li:nth-child(3n + 1):nth-last-child(2) + li { - width: 66.666%; -} -.rewards .btn-buy { - word-wrap: break-word; - padding-left: 0.2em; - text-align: left; - border: none; -} -.rewards .btn-buy.disabled { - pointer-events: none; - cursor: not-allowed; - -webkit-box-shadow: none; - -webkit-box-shadow: none; - box-shadow: none; - opacity: 0.65; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=65)"; - filter: alpha(opacity=65); -} -.rewards .btn-buy span { - position: relative; - left: -2px; -} -.rewards .btn-buy span:only-child { - position: static; - padding-left: 0.75em; -} -.rewards .btn-buy.highValue { - padding: 0.3em; - text-align: center; -} -.rewards .btn-buy.highValue span { - display: block; - margin: auto; -} -.rewards .btn-buy .shop_gold + .reward-cost { - line-height: 1; - top: -0.1em; -} -.locked-task .action-yesno label:hover:after { - height: 10px; - width: 10px; - margin: 15px auto; - border: 1px solid #222; - opacity: 0.2 !important; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=20)" !important; - filter: alpha(opacity=20) !important; -} -.locked-task .action-yesno label:focus, -.locked-task .action-yesno label:hover { - background-color: #ffcf42 !important; -} -.locked-task .task-actions a, -.locked-task .task-checker label, -.locked-task .task-text { - cursor: initial; -} -.locked-task .task-actions a:hover { - background-color: inherit !important; -} -.locked-task .task-checker label:hover:after { - content: ''; -} -.profile-modal .modal-dialog { - height: 100%; - width: auto; - max-width: 920px; - margin: 0 auto; - padding: 10px; -} -@media screen and (min-width: 768px) { - .profile-modal .modal-dialog { - padding: 30px 10px; - } -} -.profile-modal .modal-content { - height: 100%; -} -.profile-modal .modal-body { - max-height: calc(100% - 137px); - overflow-y: scroll; -} -.profile-modal .modal-footer { - margin: 0; - padding: 16px 20px; -} -.herobox { - height: 10.5em; - width: 10em; - max-width: 10em; - margin: 0; - position: relative; - cursor: pointer; - padding-top: 2em; -} -.herobox .avatar-level, -.herobox .avatar-name { - position: absolute; - z-index: 2; -} -.herobox .avatar-level { - bottom: 4px; - right: 4px; -} -.herobox .avatar-level .glyphicon-circle-arrow-up { - padding-right: 4px; -} -.herobox .avatar-level .glyphicon-plus-sign { - padding-left: 4px; -} -.herobox .avatar-name { - max-width: 132px; - top: 4px; - left: 4px; - 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; - -o-transition: opacity 0.2s ease-out; - -ms-transition: opacity 0.2s ease-out; - transition: opacity 0.2s ease-out; -} -.herobox.isUser.noBackgroundImage:not(.hasMount) .avatar-name { - opacity: 1; - -ms-filter: none; - filter: none; -} -.herobox:hover .avatar-name, -.herobox:focus .avatar-name { - opacity: 1; - -ms-filter: none; - filter: none; -} -.herobox .character-sprites { - width: 6.428571429em; - height: 6.428571429em; - margin: 0 auto; -} -.herobox .character-sprites span { - position: absolute; -} -.herobox.hasPet { - padding-top: 1.75em; -} -.herobox.hasMount { - padding-top: 0em; -} -.herobox.noBackgroundImage { - background: #fff; -} -.herobox.noBackgroundImage:hover, -.herobox.noBackgroundImage:focus { - background: #fff; -} -.herobox.noBackgroundImage.isUser { - background: #fff; -} -.herobox.noBackgroundImage.isUser:hover, -.herobox.noBackgroundImage.isUser:focus { - background: #fff; -} -.herobox .addthis_pill_style { - width: 50px !important; -} -.herobox .addthis_native_toolbox { - position: absolute; - bottom: 4px; - left: 4px; - z-index: 2; - opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; - filter: alpha(opacity=0); -} -.herobox:hover .addthis_native_toolbox { - opacity: 1; - -ms-filter: none; - filter: none; -} -.avatar-window { - float: right; - margin: 0 1em 0 0; -} -menu { - padding: 0; - margin: 0; -} -.customize-menu { - padding: 0; - width: 100%; - list-style: none; - padding-bottom: 10px; -} -.customize-menu menu:before { - content: attr(label); - display: block; - font-size: 14px; - font-weight: bold; - line-height: 2; -} -.customize-menu .cost { - display: block; - margin-bottom: 5px; -} -.customize-menu .cost .btn { - margin-left: 5px; - vertical-align: baseline; -} -.customize-option { - border: 0px solid #808080; - background-color: #ededed; - margin-bottom: 10px; - -webkit-transition: background-color 0.5s ease-out; - -moz-transition: background-color 0.5s ease-out; - -webkit-transition: background-color 0.5s ease-out; - -moz-transition: background-color 0.5s ease-out; - -o-transition: background-color 0.5s ease-out; - -ms-transition: background-color 0.5s ease-out; - transition: background-color 0.5s ease-out; -} -.customize-option:active { - background-color: #fff2cc; - -webkit-transition: none; - -moz-transition: none; - -webkit-transition: none; - -moz-transition: none; - -o-transition: none; - -ms-transition: none; - transition: none; -} -.customize-option:not(:last-of-type) { - margin-right: 10px; -} -@media (max-width: 480px) { - .avatar-window { - float: none; - margin-bottom: 1em; - } - .customize-menu { - width: 100%; - } -} -.well.limited-edition { - padding: 5px; - margin: 25px 0 0; -} -.background-unlocked { - border: 15px solid #fff; - width: 170px !important; - height: 177px !important; -} -.background-locked { - border: 15px solid #808080; - width: 170px !important; - height: 177px !important; - position: relative; -} -.background-locked i { - position: absolute; - right: -13px; - bottom: -13px; - color: #fff; - top: auto; -} -.money { - display: inline-block; - line-height: 1.5em; - padding-left: 0.75em; -} -.shop_gold, -.shop_silver, -.shop_copper { - vertical-align: top; - display: inline-block; -} -.shop_spell { - vertical-align: top; - display: inline-block; - top: -5px; -} -.btn-buy { - width: 4.5em; - min-height: 3em; - height: 100%; - padding: 0.75em 0 0 0.5em; - text-align: center; - vertical-align: top; - background-color: #d0e0e3; - color: #555; -} -.btn-buy:hover, -.btn-buy:focus, -.btn-buy.active { - background-color: #fce5cd; - text-decoration: none; -} -.btn-buy input:focus { - opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; - filter: alpha(opacity=0); -} -input:focus + a.btn-buy { - background-color: #fce5cd; - text-decoration: none; -} -.locked-task .btn-buy:hover { - background-color: #d0e0e3; - cursor: initial; -} -.rewards { - margin-bottom: 1.5em; - padding-bottom: 1.5em; - border-bottom: 1px solid rgba(0,0,0,0.1); -} -.reward-item { - background: #fff; -} -.reward-item:hover { - cursor: inherit; -} -.reward-cost { - display: inline; -} -.rewards .task-text { - padding-left: 5em; -} -.rewards .reward-img { - margin-left: 5em; -} -.rewards .task-options { - background: #fff; -} -.btn-reroll { - width: 2.5em; - 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); -} -.btn-reroll:hover, -.btn-reroll:focus { - background-color: #fce5cd; -} -.item-img { - float: left; - display: inline-block; - vertical-align: top; - height: 3em; - margin-left: -0.5em; - margin-right: 0.5em; - border-top: 0; - border-bottom: 0; -} -.gem-wallet { - cursor: pointer; - padding-top: 10px; -} -.gem-wallet .tile { - background-color: #ffe69e; -} -.gem-wallet .add-gems-btn { - opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; - filter: alpha(opacity=0); -} -.gem-wallet:hover .add-gems-btn, -.gem-wallet:focus .add-gems-btn { - opacity: 1; - -ms-filter: none; - filter: none; - background-color: #d9ead3; -} -.gem-wallet:hover .tile, -.gem-wallet:focus .tile { - opacity: 1; - -ms-filter: none; - filter: none; -} -.modal-header .gem-wallet { - padding-top: 0px; -} -.equipment-title { - display: inline-block; - vertical-align: bottom; - margin-right: 20px; -} -.well.use-costume-info { - margin-top: 10px; -} -.well.use-costume-info p:first-child { - margin-bottom: 20px; -} -.stable .static-popover { - max-width: 550px; -} -menu.pets div { - display: inline-block; - vertical-align: top; -} -.current-pet { - left: 0px; - bottom: 0px; -} -.item-drop-icon { - float: left; - padding-right: 20px; - padding-bottom: 20px; -} -.food-tray { - position: fixed; - right: 15px; - bottom: 0px; - width: 30%; - max-width: 17em; - height: 50%; - overflow-y: auto; - z-index: 1; - padding-bottom: 0; -} -.inventory-list li { - clear: both; -} -.inventory-list button.customize-option { - position: relative; -} -.inventory-list .stack-count { - position: absolute; - bottom: -6px; - right: -9px; -} -.pets-menu > div { - display: inline-block; - vertical-align: top; - padding: 0.3em; - width: 6em; - margin-top: 1em; - text-align: center; -} -.pets-menu > div p { - margin-top: -0.3em; -} -.hatchingPotion-menu > div { - display: inline-block; - vertical-align: top; - padding: 0.3em; - width: 6em; - margin-top: 1em; -} -.hatchingPotion-menu > div p { - text-align: center; - width: 6em; - margin-top: -0.5em; -} -menu.pets .customize-menu button { - position: relative; -} -menu.pets .customize-menu .progress { - width: 60%; - position: absolute; - bottom: -25px; - left: 20%; - height: 5px; -} -.pet-button { - border: none; - background: none #fff; -} -.pet-not-owned, -.mount-not-owned { - width: 81px; - height: 99px; -/* 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%) - -webkit-filter: brightness(0%) - -moz-filter: brightness(0%) - -o-filter: brightness(0%) - -ms-filter: brightness(0%)*/ -} -.pet-not-owned .PixelPaw, -.mount-not-owned .PixelPaw { - margin-top: 36px; -} -.pet-evolved { - opacity: 0.1; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=10)"; - filter: alpha(opacity=10); -} -.selectableInventory { - background-color: #90ee90 !important; - -webkit-border-radius: 50%; - border-radius: 50%; -} -.sell-inventory { - width: 162px; - height: 138px; -} -.new-stuff { - position: fixed; - text-align: center; - top: 0; - left: 0; - width: 100%; - height: 0; - z-index: 1010; -} -.new-stuff> .alert { - border-top: 0; - -webkit-border-radius: 0 0 4px 4px; - border-radius: 0 0 4px 4px; - padding-right: 14px; - display: inline-block; -} -.wide-popover { - max-width: max-content; -} -.alert-sm { - padding-top: 8px; - padding-bottom: 8px; -} -#notification-area { - position: fixed; - top: 0; - right: 0px; - z-index: 1061; -} -#notification-area .alert { - padding-top: 8px; - padding-bottom: 8px; -} -#notification-area .alert .notification-icon { - -webkit-transform: scale(0.8); - -moz-transform: scale(0.8); - -o-transform: scale(0.8); - -ms-transform: scale(0.8); - transform: scale(0.8); -} -.alert-gp { - background-color: #fbefc0; - border-color: #f6da6d; - color: #786938; -} -.alert-hp { - background-color: #f2dede; - border-color: #d89b9b; - color: #b94a48; -} -.alert-lvl { - background-color: #d9edf7; - border-color: #8cc9e7; - color: #3a87ad; -} -.alert-xp { - background-color: #dcc0fb; - border-color: #ae6df6; - color: #635673; -} -.alert-death { - background-color: #4e4e4e; - border-color: #4e4e4e; - color: #ababab; -} -.alert-mp { - background-color: #c7d3e9; - border-color: #89a2d1; - color: #003aa1; -} -.alert-crit { - background-color: #fce5cd; - border-color: #f7b877; - color: #dc5000; -} -.alert-drop { - background-color: #d9ead3; - border-color: #a5cd97; - color: #415838; -} -.alert-streak { - background-color: #f5deb3; - border-color: #ebbd68; - color: #a0522d; -} -.undo-button { - z-index: 3000; - position: absolute; - left: 5px; - top: 5px; -} -.hidden { - display: none !important; - visibility: hidden; -} -.visuallyhidden { - border: 0; - clip: rect(0 0 0 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - width: 1px; -} -.visuallyhidden.focusable:focus { - clip: auto; - height: auto; - margin: 0; - overflow: visible; - position: static; - width: auto; -} -.invisible { - visibility: hidden; -} -.transparent { - opacity: 0.5; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=50)"; - filter: alpha(opacity=50); -} -.col-centered { - margin-left: auto; - margin-right: auto; -} -@media (min-width: 768px) { - .pull-left-sm { - float: left; - } - .pull-right-sm { - float: right; - } -} -@media (min-width: 992px) { - .pull-left-md { - float: left; - } - .pull-right-md { - float: right; - } -} -@media (min-width: 1200px) { - .pull-left-lg { - float: left; - } - .pull-right-lg { - float: right; - } -} -@media (min-width: 768px) and (max-width: 991px) { - .new-row-md { - clear: left; - } -} -.header-wrap { - width: 100%; - z-index: 100; - padding: 46px 0 0 0; - background-color: #c5d3d6; - border-bottom: 1px solid rgba(0,0,0,0.2); - overflow-y: hidden; - overflow-x: auto; -} -@media screen and (max-width: 767px) { - .header-wrap { - padding: 0; - } -} -.toolbar.active ~ .header-wrap { - padding: 0; -} -.sticky-wrapper { - height: 215px; -} -.user-menu { - position: absolute; - top: 0.5em; - right: 0.5em; - font-size: 0.85em; - z-index: 1011; -} -.user-menu .site-nav { - margin-bottom: 0px; -} -.user-menu .tile { - cursor: pointer; - font-weight: 400; - color: #494949; - color: rgba(38,38,38,0.8); - background-color: #c2d7db; -} -.user-menu .tile:hover, -.user-menu .tile:focus { - background-color: #b2d4a6; -} -.user-menu .tile:active { - background-color: #98c688; -} -.user-reporter:after { - content: 'ā–¾'; - float: right; -} -.stacked > li { - display: list-item; -} -.stacked > li > a { - display: block; -} -.flyout, -.flyout-alt { - position: relative; -} -.flyout-content { - position: absolute; - top: 100%; - right: -99999px; - height: 0; - overflow: hidden; -} -.flyout:hover > .flyout-content { - right: 0; -} -.flyout-alt:hover > .flyout-content { - top: 0; - right: 100%; -} -.flyout:hover > .flyout-content, -.flyout-alt:hover > .flyout-content { - height: auto; - overflow: visible; -} -.flyout .tile { - min-width: 6.5em; -} -.stacked .tile { - outline: 0; - border: 1px solid rgba(0,0,0,0.2); - border-top: 0; -} -.site-header { - display: table; - height: 10.5em; - width: 100%; -} -.herobox-wrap { - display: table-cell; - vertical-align: middle; - width: 10em; - height: 10.5em; -} -.hero-stats { - padding: 1.1em 1em 0; - margin: 0; - display: table-cell; - vertical-align: middle; - min-width: 175px; - width: 75%; -} -@media screen and (max-width: 767px) { - .hero-stats { - padding: 1.1em 0.618em 0; - } -} -.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; - width: 2.618em; - text-align: center; - margin-right: 0.618em; -} -.hero-stats .meter { - position: relative; - overflow: hidden; - border: 1px solid #fff; - height: 1.618em; - margin: 0 0 1em; - outline: 1px solid #999; -} -.hero-stats .meter .bar { - height: 100%; - -webkit-transition: width 0.25s ease-out; - -moz-transition: width 0.25s ease-out; - -o-transition: width 0.25s ease-out; - -ms-transition: width 0.25s ease-out; - transition: width 0.25s ease-out; -} -.hero-stats .health { - background: #fcf0f0; - color: #250808; -} -.hero-stats .health .bar { - background: #da5353; - border-color: #6f1818; -} -.hero-stats .experience { - background: #fffbf0; - color: #2e2200; -} -.hero-stats .experience .bar { - background: #ffcc35; - border-color: #8a6700; -} -.hero-stats .experience .meter-text span { - margin-right: 0.236em; -} -.hero-stats .experience .meter-text span a { - color: #5c4400; -} -.hero-stats .mana { - background: #eff4fd; - color: #051228; -} -.hero-stats .mana .bar { - background: #4781e7; - border-color: #0f3577; -} -.hero-stats .meter-text { - position: absolute; - top: 0; - z-index: 4; - line-height: 1.618; - white-space: nowrap; -} -.hero-stats .meter-text.value { - right: 0.382em; -} -[class^="quest_"] + .hero-stats { - min-width: 220px; - padding: 1.618em 0 1em; -} -header .hero-stats { - background-color: #cfdbde; - border-right: 1px solid #bacbcf; -} -.party { - display: table-cell; - width: 100%; -} -button.party-invite { - display: block; - margin: 57px auto; -} -.filters { - padding: 1em 15px 0.382em; - margin-left: 0.382em; - margin-right: 0em; -} -.filters ul { - float: left; - list-style: none; - margin-right: 1em; -} -.filters .filters-controls { - border-color: #c8dbdb; -} -.filters .filters-controls li { - border-right-color: #e6efef; -} -.filters .filters-controls li.active a { - -webkit-box-shadow: 0 0 0 1px #b2cbcb !important; - box-shadow: 0 0 0 1px #b2cbcb !important; -} -.filters .filters-controls li:first-of-type { - color: #649696; -} -.filters .filters-controls li > a, -.filters .filters-controls li > button { - background-color: #f9fbfb !important; -} -.filters .filters-controls li > a:active, -.filters .filters-controls li > button:active { - background-color: #4e7474 !important; -} -@media screen and (min-width: 768px) { - .filters .filters-controls li > a:hover, - .filters .filters-controls li > button:hover { - background-color: #f2f6f6 !important; - } -} -.filters .filters-controls li > a, -.filters .filters-controls li > button, -.filters .filters-controls li > input, -.filters .filters-controls li textarea { - color: #3c5a5a !important; - border-color: #c8dbdb !important; -} -.filters .filters-controls li > input:hover, -.filters .filters-controls li textarea:hover { - border-color: #97b9b9 !important; -} -.filters .filters-controls li > input:focus, -.filters .filters-controls li textarea:focus { - border-color: #4c7373 !important; - outline: none; -} -.filters .filters-controls li > input + button:focus, -.filters .filters-controls li textarea + button:focus { - border-color: #97b9b9 !important; - background-color: #e6efef !important; - outline: none; -} -.filters .filters-controls li > input + button:active, -.filters .filters-controls li textarea + button:active { - background-color: #c8dbdb !important; -} -.filters .filters-controls li > a:nth-of-type(2) { - border-left: 1px solid #eef3f3 !important; -} -@media screen and (min-width: 768px) { - .filters .filters-controls li > div { - border: 1px solid #c8dbdb; - } -} -.filters .filters-controls li > div h4 { - color: #4c7373; - background-color: #f9fbfb; - border-color: #c8dbdb; -} -.filters .filters-controls li > div > ul:first-child:before, -.filters .filters-controls li > div > div:first-child:before { - background-color: #fff; - border-color: #c8dbdb; -} -.filters .filters-controls li > div h4:before { - background-color: #f9fbfb; - border-color: #c8dbdb; -} -.filters .filters-controls li > div ul { - border-color: #f9fbfb; -} -.filters .filters-controls li > div a, -.filters .filters-controls li > div a:link, -.filters .filters-controls li > div a:visited { - color: #3c5a5a !important; -} -.filters .filters-controls li .glyphicon { - color: #507878; -} -.filters .filters-controls li .tooltip { - border: 0; -} -.filters .filters-controls li.active a, -.filters .filters-controls li.active button { - background-color: #eef3f3 !important; - border-color: #649696 !important; -} -.filters .filters-controls li.active.filters-tags a, -.filters .filters-controls li.active.filters-tags button { - background-color: #97b9b9 !important; - border-color: #f9fbfb !important; - color: #fff !important; -} -.filters .filters-controls li.active.filters-tags a span, -.filters .filters-controls li.active.filters-tags button span { - color: #fff !important; -} -.filters .filters-tags, -.filters .filters-edit { - float: left; -} -.filters .filters-edit { - margin-bottom: 0.382em; -} -.filters .filters-tags { - margin-bottom: 0.618em; - margin-right: 0.618em; -} -.filters .filters-tags > a, -.filters .filters-tags > button { - background-color: #f0f7ed !important; -} -.filters .filters-tags > a:active, -.filters .filters-tags > button:active { - background-color: #4f823a !important; -} -@media screen and (min-width: 768px) { - .filters .filters-tags > a:hover, - .filters .filters-tags > button:hover { - background-color: #e9f3e5 !important; - } -} -.filters .filters-tags > a, -.filters .filters-tags > button, -.filters .filters-tags > input, -.filters .filters-tags textarea { - color: #3d652d !important; - border-color: #c3dfb7 !important; -} -.filters .filters-tags > input:hover, -.filters .filters-tags textarea:hover { - border-color: #94c67f !important; -} -.filters .filters-tags > input:focus, -.filters .filters-tags textarea:focus { - border-color: #4e8039 !important; - outline: none; -} -.filters .filters-tags > input + button:focus, -.filters .filters-tags textarea + button:focus { - border-color: #94c67f !important; - background-color: #dfeed8 !important; - outline: none; -} -.filters .filters-tags > input + button:active, -.filters .filters-tags textarea + button:active { - background-color: #c3dfb7 !important; -} -.filters .filters-tags > a:nth-of-type(2) { - border-left: 1px solid #e5f1e0 !important; -} -@media screen and (min-width: 768px) { - .filters .filters-tags > div { - border: 1px solid #c3dfb7; - } -} -.filters .filters-tags > div h4 { - color: #4e8039; - background-color: #f0f7ed; - border-color: #c3dfb7; -} -.filters .filters-tags > div > ul:first-child:before, -.filters .filters-tags > div > div:first-child:before { - background-color: #fff; - border-color: #c3dfb7; -} -.filters .filters-tags > div h4:before { - background-color: #f0f7ed; - border-color: #c3dfb7; -} -.filters .filters-tags > div ul { - border-color: #f0f7ed; -} -.filters .filters-tags > div a, -.filters .filters-tags > div a:link, -.filters .filters-tags > div a:visited { - color: #3d652d !important; -} -.filters .filters-tags .glyphicon { - color: #52863c; -} -.filters .filters-tags .tooltip { - border: 0; -} -.filters .filters-tags.active a, -.filters .filters-tags.active button { - background-color: #e5f1e0 !important; - border-color: #66a84a !important; -} -.filters .filters-tags.active.filters-tags a, -.filters .filters-tags.active.filters-tags button { - background-color: #94c67f !important; - border-color: #f0f7ed !important; - color: #fff !important; -} -.filters .filters-tags.active.filters-tags a span, -.filters .filters-tags.active.filters-tags button span { - color: #fff !important; -} -.filters .filters-tags.challenge > a, -.filters .filters-tags.challenge > button { - background-color: #ead9ea !important; -} -.filters .filters-tags.challenge > a:active, -.filters .filters-tags.challenge > button:active { - background-color: #713e71 !important; -} -@media screen and (min-width: 768px) { - .filters .filters-tags.challenge > a:hover, - .filters .filters-tags.challenge > button:hover { - background-color: #e6d2e6 !important; - } -} -.filters .filters-tags.challenge > a, -.filters .filters-tags.challenge > button, -.filters .filters-tags.challenge > input, -.filters .filters-tags.challenge textarea { - color: #573057 !important; - border-color: #d0aad0 !important; -} -.filters .filters-tags.challenge > input:hover, -.filters .filters-tags.challenge textarea:hover { - border-color: #b57ab5 !important; -} -.filters .filters-tags.challenge > input:focus, -.filters .filters-tags.challenge textarea:focus { - border-color: #6f3d6f !important; - outline: none; -} -.filters .filters-tags.challenge > input + button:focus, -.filters .filters-tags.challenge textarea + button:focus { - border-color: #b57ab5 !important; - background-color: #e0c7e0 !important; - outline: none; -} -.filters .filters-tags.challenge > input + button:active, -.filters .filters-tags.challenge textarea + button:active { - background-color: #d0aad0 !important; -} -.filters .filters-tags.challenge > a:nth-of-type(2) { - border-left: 1px solid #e4cee4 !important; -} -@media screen and (min-width: 768px) { - .filters .filters-tags.challenge > div { - border: 1px solid #d0aad0; - } -} -.filters .filters-tags.challenge > div h4 { - color: #6f3d6f; - background-color: #ead9ea; - border-color: #d0aad0; -} -.filters .filters-tags.challenge > div > ul:first-child:before, -.filters .filters-tags.challenge > div > div:first-child:before { - background-color: #fff; - border-color: #d0aad0; -} -.filters .filters-tags.challenge > div h4:before { - background-color: #ead9ea; - border-color: #d0aad0; -} -.filters .filters-tags.challenge > div ul { - border-color: #ead9ea; -} -.filters .filters-tags.challenge > div a, -.filters .filters-tags.challenge > div a:link, -.filters .filters-tags.challenge > div a:visited { - color: #573057 !important; -} -.filters .filters-tags.challenge .glyphicon { - color: #744074; -} -.filters .filters-tags.challenge .tooltip { - border: 0; -} -.filters .filters-tags.challenge.active a, -.filters .filters-tags.challenge.active button { - background-color: #e4cee4 !important; - border-color: #915091 !important; -} -.filters .filters-tags.challenge.active.filters-tags a, -.filters .filters-tags.challenge.active.filters-tags button { - background-color: #b57ab5 !important; - border-color: #ead9ea !important; - color: #fff !important; -} -.filters .filters-tags.challenge.active.filters-tags a span, -.filters .filters-tags.challenge.active.filters-tags button span { - color: #fff !important; -} -.filters .filters-tags.challenge span { - margin-right: 0.382em; -} -.filters .filters-tags.challenge form { - display: none; -} -.filters .filters-tags .habitica-emoji { - margin-top: -0.5em; -} -.filters markdown { - display: inline-block; -} -.filters .hrpg-input-group { - display: inline-block; - margin-right: 0.618em; -} -.filters .hrpg-input-group > a, -.filters .hrpg-input-group > button { - background-color: #f6f8f9 !important; -} -.filters .hrpg-input-group > a:active, -.filters .hrpg-input-group > button:active { - background-color: #4d6673 !important; -} -@media screen and (min-width: 768px) { - .filters .hrpg-input-group > a:hover, - .filters .hrpg-input-group > button:hover { - background-color: #eff3f4 !important; - } -} -.filters .hrpg-input-group > a, -.filters .hrpg-input-group > button, -.filters .hrpg-input-group > input, -.filters .hrpg-input-group textarea { - color: #3b4f59 !important; - border-color: #c6d3d9 !important; -} -.filters .hrpg-input-group > input:hover, -.filters .hrpg-input-group textarea:hover { - border-color: #95acb8 !important; -} -.filters .hrpg-input-group > input:focus, -.filters .hrpg-input-group textarea:focus { - border-color: #4c6571 !important; - outline: none; -} -.filters .hrpg-input-group > input + button:focus, -.filters .hrpg-input-group textarea + button:focus { - border-color: #95acb8 !important; - background-color: #e4eaed !important; - outline: none; -} -.filters .hrpg-input-group > input + button:active, -.filters .hrpg-input-group textarea + button:active { - background-color: #c6d3d9 !important; -} -.filters .hrpg-input-group > a:nth-of-type(2) { - border-left: 1px solid #ebeff1 !important; -} -@media screen and (min-width: 768px) { - .filters .hrpg-input-group > div { - border: 1px solid #c6d3d9; - } -} -.filters .hrpg-input-group > div h4 { - color: #4c6571; - background-color: #f6f8f9; - border-color: #c6d3d9; -} -.filters .hrpg-input-group > div > ul:first-child:before, -.filters .hrpg-input-group > div > div:first-child:before { - background-color: #fff; - border-color: #c6d3d9; -} -.filters .hrpg-input-group > div h4:before { - background-color: #f6f8f9; - border-color: #c6d3d9; -} -.filters .hrpg-input-group > div ul { - border-color: #f6f8f9; -} -.filters .hrpg-input-group > div a, -.filters .hrpg-input-group > div a:link, -.filters .hrpg-input-group > div a:visited { - color: #3b4f59 !important; -} -.filters .hrpg-input-group .glyphicon { - color: #4f6a77; -} -.filters .hrpg-input-group .tooltip { - border: 0; -} -.filters .hrpg-input-group.active a, -.filters .hrpg-input-group.active button { - background-color: #ebeff1 !important; - border-color: #638494 !important; -} -.filters .hrpg-input-group.active.filters-tags a, -.filters .hrpg-input-group.active.filters-tags button { - background-color: #95acb8 !important; - border-color: #f6f8f9 !important; - color: #fff !important; -} -.filters .hrpg-input-group.active.filters-tags a span, -.filters .hrpg-input-group.active.filters-tags button span { - color: #fff !important; -} -.filters .filters-search { - margin-bottom: 0.618em; - max-width: 180px; -} -.task-column::-webkit-scrollbar { - width: 12px; -} -.task-column::-webkit-scrollbar-thumb { - border-width: 1px 1px 1px 2px; -} -.task-column::-webkit-scrollbar-track { - border-width: 0; -} -.task-column::-webkit-scrollbar { - height: 16px; - overflow: visible; - width: 16px; -} -.task-column::-webkit-scrollbar-button { - height: 0; - width: 0; -} -.task-column::-webkit-scrollbar-track { - -webkit-background-clip: padding; - -moz-background-clip: padding; - background-clip: padding-box; - border: solid transparent; - border-width: 0 0 0 4px; -} -.task-column::-webkit-scrollbar-track:horizontal { - 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); -} -.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); -} -.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); -} -.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); -} -.task-column::-webkit-scrollbar-thumb { - background-color: rgba(150,150,150,0.2); - -webkit-background-clip: padding; - -moz-background-clip: padding; - background-clip: padding-box; - border: solid transparent; - 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); -} -.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); -} -.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); -} -.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); -} -.task-column::-webkit-scrollbar-track { - border-width: 0 1px 0 6px; -} -.task-column::-webkit-scrollbar-track:horizontal { - 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); -} -.task-column::-webkit-scrollbar-thumb { - border-width: 0 1px 0 6px; -} -.task-column::-webkit-scrollbar-thumb:horizontal { - border-width: 6px 0 1px; -} -.task-column::-webkit-scrollbar-corner { - background: transparent; -} -.border-right { - border-right: 1px solid #ddd; -} -.border-left { - border-left: 1px solid #ddd; -} -.tab-notification { - color: #fff; - background-color: #51a351; -} -.chat-form { - margin-bottom: 10px; - position: relative; -} -.chat-form textarea { - margin-bottom: 0.618em; - line-height: 1.618em; -} -.guidelines-not-accepted p { - border: 1px solid #ccc; - padding: 1em; - margin-bottom: 0.5em; -} -.slight-vertical-padding { - clear: both; - padding-top: 0.618em; -} -.chat-buttons { - float: right; -} -.chat-buttons input { - margin-right: 0.618em; -} -.chat-message markdown, -.chat-message span.time { - padding: 0 3px; - line-height: 1.618em; -} -.chat-message .long-title .tooltip { - white-space: normal; -} -.chat-message span.time { - white-space: nowrap; -} -.chat-message .chat-plus-one { - opacity: 0; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=0)"; - filter: alpha(opacity=0); - background-color: #eee; - padding: 3px 3px 0px 3px; -} -.chat-message:hover .chat-plus-one { - opacity: 1; - -ms-filter: none; - filter: none; - color: #999; -} -.tavern-chat li, -.party-chat li { - padding-top: 15px; - padding-bottom: 15px; - border-bottom: 1px solid #ddd; - word-wrap: break-word; - position: relative; -} -.tavern-chat li.highlight, -.party-chat li.highlight { - background: #eee; -} -.tavern-chat li label, -.party-chat li label { - margin-right: 5px; -} -.tavern-chat li .float-label, -.party-chat li .float-label { - float: left; - position: absolute; - top: 17px; -} -.tavern-chat li .hidden-label, -.party-chat li .hidden-label { - visibility: hidden; -} -.tavern-chat li .scrollable-message, -.party-chat li .scrollable-message { - max-height: 6.472em; - overflow-y: auto; -} -.tavern-chat li .scrollable-message h1, -.party-chat li .scrollable-message h1, -.tavern-chat li .scrollable-message h2, -.party-chat li .scrollable-message h2, -.tavern-chat li .scrollable-message h3, -.party-chat li .scrollable-message h3, -.tavern-chat li .scrollable-message h4, -.party-chat li .scrollable-message h4, -.tavern-chat li .scrollable-message h5, -.party-chat li .scrollable-message h5 { - margin-top: 5px; - margin-bottom: 5px; -} -.tavern-chat li .expander, -.party-chat li .expander { - float: right; - position: absolute; - bottom: 18px; - right: 20px; - background: #fff; - max-width: 20px; - max-height: 20px; - width: 100%; - height: 100%; - text-align: center; -} -.tavern-chat .own-message, -.party-chat .own-message { - border-left: 4px solid #333; - padding-left: 2px; -} -.tavern-chat markdown p:first-child, -.party-chat markdown p:first-child { - display: inline; -} -.tavern-chat markdown img, -.party-chat markdown img { - max-height: 50px; - vertical-align: top; -} -.tavern-chat markdown li, -.party-chat markdown li { - border: 0; - padding-top: 0px; - padding-bottom: 0px; -} -.tavern-chat markdown blockquote, -.party-chat markdown blockquote { - padding: 0 15px; - color: #777; - border-left: 4px solid #ddd; - margin: 5px 0px 5px 0px; - font-size: 14px; -} -.tavern-chat markdown blockquote p, -.party-chat markdown blockquote p { - line-height: 1.3em; - margin-bottom: 0.7em; -} -.tavern-chat markdown blockquote p:first-child, -.party-chat markdown blockquote p:first-child { - display: block; -} -.tavern-chat markdown blockquote>:last-child, -.party-chat markdown blockquote>:last-child { - margin-bottom: 0; -} -.panel-tiers div { - display: none; - margin-top: 10px; -} -.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; -} -.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; -} -.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; -} -.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; -} -.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; -} -.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; -} -.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; -} -.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; -} -.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; -} -.label-npc { - background-color: #000 !important; - text-shadow: -1px -1px 1px #000, 1px -1px 1px #000, -1px 1px 1px #000, 1px 1px 1px #000; - color: #0f0 !important; -} -#market-tab { - position: relative; - height: 500px; -} -.buttonList li { - margin: 5px; -} -.option-group .option-time { - padding: 0px 5px; -} -.list-at-user { - width: 100%; - max-width: 120px; - position: absolute; - background: #fff; - border: 1px solid #a4a4a4; - z-index: 10; -} -.list-at-user li { - line-height: 14px; - border-bottom: 1px solid #a4a4a4; - list-style-type: none; -} -.list-at-user li:hover { - cursor: pointer; - background: #b9dff4; -} -.list-at-user li span { - margin: 2px; - font-size: 11.844px; - margin-left: 5px; - display: inline-block; -} -.list-cur { - background: #b9dff4; -} -.npc_alex_container { - margin-bottom: 20px; -} -.npc_matt { - margin-bottom: 20px; -} -.toolbar-bailey-container { - width: 55px; - margin-right: 0.618em; - position: relative; -} -@media screen and (max-width: 767px) { - .toolbar-bailey-container { - margin-right: 0px; - } -} -.npc_bailey { - float: left; -} -.npc_bailey_broken { - float: left; -} -.npc_bailey_head { - position: absolute; - top: -17px; - height: 39px !important; - cursor: pointer; -} -.npc_justin.float-left { - float: left; - margin-right: 5px; - margin-bottom: 5px; -} -.npc_justin_broken.float-left { - float: left; - margin-right: 5px; - margin-bottom: 5px; -} -.static-popover { - z-index: 0; - display: block; - position: relative; -} -.popover-navigation { - clear: both; -} -ul.challenge-accordion-header-specs { - list-style: none; -} -ul.challenge-accordion-header-specs li { - background-color: #ffe69e; - margin: 2px 5px; - float: left; -} -ul.challenge-accordion-header-specs li.bg-transparent { - background-color: transparent; -} -ul.challenge-accordion-header-specs li.bg-transparent .glyphicon { - color: #fff; - margin-right: 5px; -} -#back-to-challenges, -#create-challenge-btn { - margin-bottom: 10px; -} -#challenges-filters h3 { - margin-top: 0px; -} -.select-class .herobox { - width: 115px; -} -.select-class .character-sprites { - width: 0px; - height: 0px; - margin: 0px; - margin-left: 5px; -} -html.applying-action, -html.applying-action * { - cursor: copy !important; -} -.cast-target:hover { - border: 5px solid #008000 !important; -} -.selected-class { - background-color: #008000; -} -.quest_collected_true { - color: #ccc; - text-decoration: line-through; -} -quest-rewards hr { - clear: both; -} -.quest-icon { - margin: 0.6em; -} -.quest-participants .accepted { - color: #2db200; -} -.quest-participants .rejected { - color: #b30409; -} -.subscription-features tr td { - vertical-align: middle; - text-align: center; -} -.subscription-features tr th { - max-width: 150px; - font-weight: normal; -} -.subscription-features tr th.feature-name { - text-align: center; - font-weight: bold; -} -.dashed-underline { - border-bottom: 1px dashed #000; - text-decoration: none; - cursor: pointer; -} -.buy-gems .form-inline .form-control { - padding: 6px; -} -p.resubscribe { - margin-top: 20px; -} -.buy-gems a.purchase { - height: 25px; - line-height: 12px; - font-size: 12px; - margin: 5px; -} -p.resubscribe a.purchase { - display: block; -} -.buy-gems button.customize-option { - position: relative; -} -.buy-gems .stack-count { - position: absolute; - bottom: -6px; - right: -9px; -} -.buy-gems .gem-count-popover { - display: block; -} -.buy-gems h3 { - margin-top: 0px; -} -.buy-gems .well { - margin-top: 15px; -} -.noninteractive-button { - padding: 0.5em; - margin-right: 0.5em; -} -.nav, -.pagination, -.carousel, -.panel-title a { - cursor: pointer; -} -.nav-tabs { - margin-bottom: 15px; -} -img { - max-width: 100%; -} -.hint { - border-bottom: 1px dotted #099; - text-decoration: none; - cursor: help; -} -a.hint { - border-bottom: 1px dotted #099; - text-decoration: none; - cursor: pointer; -} -a.hint:hover { - text-decoration: none; -} -.vertical-20 { - vertical-align: 20%; -} -.popover hr { - margin: 10px 0; -} -ul { - list-style-position: inside; -} -li.spaced { - margin: auto auto 0.5em 3em; -} -.group-leave-join .glyphicon-ban-circle { - color: #fff; - margin-right: 5px; -} -@media (max-width: 767px) { - .container-fluid { - padding-right: 5px; - padding-left: 5px; - } - .container-fluid > .row { - margin-left: -5px; - margin-right: -5px; - } - .tasks-lists .row .col-md-3 { - padding-right: 0; - padding-left: 0; - } -} -.flex-column { - display: -webkit-box; - display: -moz-box; - display: -webkit-flex; - display: -ms-flexbox; - display: box; - display: flex; - -webkit-box-orient: vertical; - -moz-box-orient: vertical; - -o-box-orient: vertical; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; -} -.row-margin { - margin: auto auto 1em auto; -} -.reduce-top-margin { - margin-top: -10px; -} -.slight-right-margin { - margin-right: 1em; -} -.footer { - padding-top: 20px; - margin-top: 40px; - padding-bottom: 20px; - border-top: 1px solid #e5e5e5; - background-color: #f5f5f5; -} -.toolbar { - top: 0; - right: 0; - width: 100%; - z-index: 998; - background-color: #f4f7f7; - border-bottom: 1px solid #bacbcf; -} -.toolbar.active { - width: auto; -} -@media screen and (min-width: 768px) { - .toolbar { - position: fixed; - } -} -.toolbar-toggle { - float: right; - padding: 0.9em 0.3em; - overflow: hidden; - width: 1.6em; - z-index: 4000; - border: none; - border-left: 1px solid #c6d6d6; - background: none; - background-color: #e9efef; -} -.toolbar-toggle span { - display: inline-block; - color: #87a8a8; -} -.toolbar-toggle .glyphicon { - -webkit-animation: toggle-open 0.382s linear both; - -moz-animation: toggle-open 0.382s linear both; - -o-animation: toggle-open 0.382s linear both; - -ms-animation: toggle-open 0.382s linear both; - animation: toggle-open 0.382s linear both; -} -.toolbar-toggle .toggle-text { - display: none; - padding-left: 0.382em; -} -.toolbar-toggle:focus, -.toolbar-toggle:active { - outline: none; -} -.toolbar-toggle:hover, -.toolbar-toggle.active, -.toolbar-toggle:focus { - width: auto; - padding-left: 0.618em; - padding-right: 0.618em; -} -.toolbar-toggle:hover .toggle-text, -.toolbar-toggle.active .toggle-text, -.toolbar-toggle:focus .toggle-text { - display: inline-block; - padding-left: 0.382em; -} -.toolbar-toggle:hover .toggle-open, -.toolbar-toggle.active .toggle-open, -.toolbar-toggle:focus .toggle-open { - display: none; -} -.toolbar-toggle.active span { - color: #3e5555; -} -.toolbar-toggle.active .toggle-close { - display: none; -} -.toolbar-toggle.active .toggle-open { - display: inline-block; -} -.toolbar-toggle:hover, -.toolbar-toggle:focus { - background-color: #e9efef; - border-left: 1px solid #c6d6d6; -} -.toolbar-toggle.active, -.toolbar-toggle:focus.active { - background-color: #c5d3d6; -} -.toolbar-toggle.active .glyphicon, -.toolbar-toggle:focus.active .glyphicon { - -webkit-animation: toggle-close 0.382s linear both; - -moz-animation: toggle-close 0.382s linear both; - -o-animation: toggle-close 0.382s linear both; - -ms-animation: toggle-close 0.382s linear both; - animation: toggle-close 0.382s linear both; -} -.toolbar-toggle:focus.active { - border-left: 1px solid #87a8a8; - border-bottom: 1px solid #87a8a8; -} -.toolbar-container > ul > li { - display: inline-block; -} -.toolbar-mobile-nav { - display: inline-block; - padding-left: 0.618em; - padding-right: 0.618em; -} -@media screen and (min-width: 768px) { - .toolbar-mobile-nav { - display: none; - } -} -.toolbar-mobile > a, -.toolbar-mobile > button { - background-color: #f4f7f7 !important; -} -.toolbar-mobile > a:active, -.toolbar-mobile > button:active { - background-color: #506e6e !important; -} -@media screen and (min-width: 768px) { - .toolbar-mobile > a:hover, - .toolbar-mobile > button:hover { - background-color: #edf2f2 !important; - } -} -.toolbar-mobile > a, -.toolbar-mobile > button, -.toolbar-mobile > input, -.toolbar-mobile textarea { - color: #3e5555 !important; - border-color: #c6d6d6 !important; -} -.toolbar-mobile > input:hover, -.toolbar-mobile textarea:hover { - border-color: #97b3b3 !important; -} -.toolbar-mobile > input:focus, -.toolbar-mobile textarea:focus { - border-color: #4f6d6d !important; - outline: none; -} -.toolbar-mobile > input + button:focus, -.toolbar-mobile textarea + button:focus { - border-color: #97b3b3 !important; - background-color: #e2eaea !important; - outline: none; -} -.toolbar-mobile > input + button:active, -.toolbar-mobile textarea + button:active { - background-color: #c6d6d6 !important; -} -.toolbar-mobile > a:nth-of-type(2) { - border-left: 1px solid #e9efef !important; -} -@media screen and (min-width: 768px) { - .toolbar-mobile > div { - border: 1px solid #c6d6d6; - } -} -.toolbar-mobile > div h4 { - color: #4f6d6d; - background-color: #f4f7f7; - border-color: #c6d6d6; -} -.toolbar-mobile > div > ul:first-child:before, -.toolbar-mobile > div > div:first-child:before { - background-color: #fff; - border-color: #c6d6d6; -} -.toolbar-mobile > div h4:before { - background-color: #f4f7f7; - border-color: #c6d6d6; -} -.toolbar-mobile > div ul { - border-color: #f4f7f7; -} -.toolbar-mobile > div a, -.toolbar-mobile > div a:link, -.toolbar-mobile > div a:visited { - color: #3e5555 !important; -} -.toolbar-mobile .glyphicon { - color: #537272; -} -.toolbar-mobile .tooltip { - border: 0; -} -.toolbar-mobile.active a, -.toolbar-mobile.active button { - background-color: #e9efef !important; - border-color: #678e8e !important; -} -.toolbar-mobile.active.filters-tags a, -.toolbar-mobile.active.filters-tags button { - background-color: #97b3b3 !important; - border-color: #f4f7f7 !important; - color: #fff !important; -} -.toolbar-mobile.active.filters-tags a span, -.toolbar-mobile.active.filters-tags button span { - color: #fff !important; -} -.toolbar-nav { - white-space: nowrap; - float: left; - padding: 0.618em; -} -@media screen and (max-width: 767px) { - .toolbar-nav { - display: none; - } -} -.toolbar-nav > li { - margin-right: 0.618em; -} -.toolbar-nav .toolbar-button-dropdown > a, -.toolbar-nav .toolbar-button-dropdown > button { - background-color: #f4f7f7 !important; -} -.toolbar-nav .toolbar-button-dropdown > a:active, -.toolbar-nav .toolbar-button-dropdown > button:active { - background-color: #506e6e !important; -} -@media screen and (min-width: 768px) { - .toolbar-nav .toolbar-button-dropdown > a:hover, - .toolbar-nav .toolbar-button-dropdown > button:hover { - background-color: #edf2f2 !important; - } -} -.toolbar-nav .toolbar-button-dropdown > a, -.toolbar-nav .toolbar-button-dropdown > button, -.toolbar-nav .toolbar-button-dropdown > input, -.toolbar-nav .toolbar-button-dropdown textarea { - color: #3e5555 !important; - border-color: #c6d6d6 !important; -} -.toolbar-nav .toolbar-button-dropdown > input:hover, -.toolbar-nav .toolbar-button-dropdown textarea:hover { - border-color: #97b3b3 !important; -} -.toolbar-nav .toolbar-button-dropdown > input:focus, -.toolbar-nav .toolbar-button-dropdown textarea:focus { - border-color: #4f6d6d !important; - outline: none; -} -.toolbar-nav .toolbar-button-dropdown > input + button:focus, -.toolbar-nav .toolbar-button-dropdown textarea + button:focus { - border-color: #97b3b3 !important; - background-color: #e2eaea !important; - outline: none; -} -.toolbar-nav .toolbar-button-dropdown > input + button:active, -.toolbar-nav .toolbar-button-dropdown textarea + button:active { - background-color: #c6d6d6 !important; -} -.toolbar-nav .toolbar-button-dropdown > a:nth-of-type(2) { - border-left: 1px solid #e9efef !important; -} -@media screen and (min-width: 768px) { - .toolbar-nav .toolbar-button-dropdown > div { - border: 1px solid #c6d6d6; - } -} -.toolbar-nav .toolbar-button-dropdown > div h4 { - color: #4f6d6d; - background-color: #f4f7f7; - border-color: #c6d6d6; -} -.toolbar-nav .toolbar-button-dropdown > div > ul:first-child:before, -.toolbar-nav .toolbar-button-dropdown > div > div:first-child:before { - background-color: #fff; - border-color: #c6d6d6; -} -.toolbar-nav .toolbar-button-dropdown > div h4:before { - background-color: #f4f7f7; - border-color: #c6d6d6; -} -.toolbar-nav .toolbar-button-dropdown > div ul { - border-color: #f4f7f7; -} -.toolbar-nav .toolbar-button-dropdown > div a, -.toolbar-nav .toolbar-button-dropdown > div a:link, -.toolbar-nav .toolbar-button-dropdown > div a:visited { - color: #3e5555 !important; -} -.toolbar-nav .toolbar-button-dropdown .glyphicon { - color: #537272; -} -.toolbar-nav .toolbar-button-dropdown .tooltip { - border: 0; -} -.toolbar-nav .toolbar-button-dropdown.active a, -.toolbar-nav .toolbar-button-dropdown.active button { - background-color: #e9efef !important; - border-color: #678e8e !important; -} -.toolbar-nav .toolbar-button-dropdown.active.filters-tags a, -.toolbar-nav .toolbar-button-dropdown.active.filters-tags button { - background-color: #97b3b3 !important; - border-color: #f4f7f7 !important; - color: #fff !important; -} -.toolbar-nav .toolbar-button-dropdown.active.filters-tags a span, -.toolbar-nav .toolbar-button-dropdown.active.filters-tags button span { - color: #fff !important; -} -.toolbar-nav .toolbar-button-dropdown.highlight > a span.glyphicon { - margin-right: 0.382em !important; -} -.toolbar-nav .toolbar-button > a, -.toolbar-nav .toolbar-button > button { - background-color: #f4f7f7 !important; -} -.toolbar-nav .toolbar-button > a:active, -.toolbar-nav .toolbar-button > button:active { - background-color: #506e6e !important; -} -@media screen and (min-width: 768px) { - .toolbar-nav .toolbar-button > a:hover, - .toolbar-nav .toolbar-button > button:hover { - background-color: #edf2f2 !important; - } -} -.toolbar-nav .toolbar-button > a, -.toolbar-nav .toolbar-button > button, -.toolbar-nav .toolbar-button > input, -.toolbar-nav .toolbar-button textarea { - color: #3e5555 !important; - border-color: #c6d6d6 !important; -} -.toolbar-nav .toolbar-button > input:hover, -.toolbar-nav .toolbar-button textarea:hover { - border-color: #97b3b3 !important; -} -.toolbar-nav .toolbar-button > input:focus, -.toolbar-nav .toolbar-button textarea:focus { - border-color: #4f6d6d !important; - outline: none; -} -.toolbar-nav .toolbar-button > input + button:focus, -.toolbar-nav .toolbar-button textarea + button:focus { - border-color: #97b3b3 !important; - background-color: #e2eaea !important; - outline: none; -} -.toolbar-nav .toolbar-button > input + button:active, -.toolbar-nav .toolbar-button textarea + button:active { - background-color: #c6d6d6 !important; -} -.toolbar-nav .toolbar-button > a:nth-of-type(2) { - border-left: 1px solid #e9efef !important; -} -@media screen and (min-width: 768px) { - .toolbar-nav .toolbar-button > div { - border: 1px solid #c6d6d6; - } -} -.toolbar-nav .toolbar-button > div h4 { - color: #4f6d6d; - background-color: #f4f7f7; - border-color: #c6d6d6; -} -.toolbar-nav .toolbar-button > div > ul:first-child:before, -.toolbar-nav .toolbar-button > div > div:first-child:before { - background-color: #fff; - border-color: #c6d6d6; -} -.toolbar-nav .toolbar-button > div h4:before { - background-color: #f4f7f7; - border-color: #c6d6d6; -} -.toolbar-nav .toolbar-button > div ul { - border-color: #f4f7f7; -} -.toolbar-nav .toolbar-button > div a, -.toolbar-nav .toolbar-button > div a:link, -.toolbar-nav .toolbar-button > div a:visited { - color: #3e5555 !important; -} -.toolbar-nav .toolbar-button .glyphicon { - color: #537272; -} -.toolbar-nav .toolbar-button .tooltip { - border: 0; -} -.toolbar-nav .toolbar-button.active a, -.toolbar-nav .toolbar-button.active button { - background-color: #e9efef !important; - border-color: #678e8e !important; -} -.toolbar-nav .toolbar-button.active.filters-tags a, -.toolbar-nav .toolbar-button.active.filters-tags button { - background-color: #97b3b3 !important; - border-color: #f4f7f7 !important; - color: #fff !important; -} -.toolbar-nav .toolbar-button.active.filters-tags a span, -.toolbar-nav .toolbar-button.active.filters-tags button span { - color: #fff !important; -} -.toolbar-nav .toolbar-button > a { - display: inline-block; - height: 100%; -} -.toolbar-bailey, -.toolbar-wallet, -.toolbar-options, -.toolbar-subscribe { - float: right; - padding: 0.618em 0.618em 0.618em 0; -} -@media screen and (max-width: 767px) { - .toolbar-bailey, - .toolbar-wallet, - .toolbar-options, - .toolbar-subscribe { - float: none; - display: inline-block; - padding: 0.618em 0.382em 0.618em 0; - } -} -.toolbar-wallet .gem-wallet .Gems { - margin-top: 0em; -} -.toolbar-wallet .gem-wallet span { - display: inline-block; - vertical-align: top; - padding-top: 0.236em; -} -.toolbar-currency { - margin-top: 0.236em; - margin-bottom: 0.236em; - vertical-align: top; -} -.toolbar-currency.gold { - color: #946e00; -} -.toolbar-currency.silver { - color: #999; -} -.toolbar-options > li, -.toolbar-wallet > li { - margin-left: 0.618em; -} -@media screen and (max-width: 767px) { - .toolbar-options > li { - padding-left: 0em; - margin-left: 0em; - margin-right: 0.618em; - } -} -.toolbar-sync > a, -.toolbar-settings > a, -.toolbar-audio > a, -.toolbar-sync > button, -.toolbar-settings > button, -.toolbar-audio > button { - background-color: #f4f7f7 !important; -} -.toolbar-sync > a:active, -.toolbar-settings > a:active, -.toolbar-audio > a:active, -.toolbar-sync > button:active, -.toolbar-settings > button:active, -.toolbar-audio > button:active { - background-color: #506e6e !important; -} -@media screen and (min-width: 768px) { - .toolbar-sync > a:hover, - .toolbar-settings > a:hover, - .toolbar-audio > a:hover, - .toolbar-sync > button:hover, - .toolbar-settings > button:hover, - .toolbar-audio > button:hover { - background-color: #edf2f2 !important; - } -} -.toolbar-sync > a, -.toolbar-settings > a, -.toolbar-audio > a, -.toolbar-sync > button, -.toolbar-settings > button, -.toolbar-audio > button, -.toolbar-sync > input, -.toolbar-settings > input, -.toolbar-audio > input, -.toolbar-sync textarea, -.toolbar-settings textarea, -.toolbar-audio textarea { - color: #3e5555 !important; - border-color: #c6d6d6 !important; -} -.toolbar-sync > input:hover, -.toolbar-settings > input:hover, -.toolbar-audio > input:hover, -.toolbar-sync textarea:hover, -.toolbar-settings textarea:hover, -.toolbar-audio textarea:hover { - border-color: #97b3b3 !important; -} -.toolbar-sync > input:focus, -.toolbar-settings > input:focus, -.toolbar-audio > input:focus, -.toolbar-sync textarea:focus, -.toolbar-settings textarea:focus, -.toolbar-audio textarea:focus { - border-color: #4f6d6d !important; - outline: none; -} -.toolbar-sync > input + button:focus, -.toolbar-settings > input + button:focus, -.toolbar-audio > input + button:focus, -.toolbar-sync textarea + button:focus, -.toolbar-settings textarea + button:focus, -.toolbar-audio textarea + button:focus { - border-color: #97b3b3 !important; - background-color: #e2eaea !important; - outline: none; -} -.toolbar-sync > input + button:active, -.toolbar-settings > input + button:active, -.toolbar-audio > input + button:active, -.toolbar-sync textarea + button:active, -.toolbar-settings textarea + button:active, -.toolbar-audio textarea + button:active { - background-color: #c6d6d6 !important; -} -.toolbar-sync > a:nth-of-type(2), -.toolbar-settings > a:nth-of-type(2), -.toolbar-audio > a:nth-of-type(2) { - border-left: 1px solid #e9efef !important; -} -@media screen and (min-width: 768px) { - .toolbar-sync > div, - .toolbar-settings > div, - .toolbar-audio > div { - border: 1px solid #c6d6d6; - } -} -.toolbar-sync > div h4, -.toolbar-settings > div h4, -.toolbar-audio > div h4 { - color: #4f6d6d; - background-color: #f4f7f7; - border-color: #c6d6d6; -} -.toolbar-sync > div > ul:first-child:before, -.toolbar-settings > div > ul:first-child:before, -.toolbar-audio > div > ul:first-child:before, -.toolbar-sync > div > div:first-child:before, -.toolbar-settings > div > div:first-child:before, -.toolbar-audio > div > div:first-child:before { - background-color: #fff; - border-color: #c6d6d6; -} -.toolbar-sync > div h4:before, -.toolbar-settings > div h4:before, -.toolbar-audio > div h4:before { - background-color: #f4f7f7; - border-color: #c6d6d6; -} -.toolbar-sync > div ul, -.toolbar-settings > div ul, -.toolbar-audio > div ul { - border-color: #f4f7f7; -} -.toolbar-sync > div a, -.toolbar-settings > div a, -.toolbar-audio > div a, -.toolbar-sync > div a:link, -.toolbar-settings > div a:link, -.toolbar-audio > div a:link, -.toolbar-sync > div a:visited, -.toolbar-settings > div a:visited, -.toolbar-audio > div a:visited { - color: #3e5555 !important; -} -.toolbar-sync .glyphicon, -.toolbar-settings .glyphicon, -.toolbar-audio .glyphicon { - color: #537272; -} -.toolbar-sync .tooltip, -.toolbar-settings .tooltip, -.toolbar-audio .tooltip { - border: 0; -} -.toolbar-sync.active a, -.toolbar-settings.active a, -.toolbar-audio.active a, -.toolbar-sync.active button, -.toolbar-settings.active button, -.toolbar-audio.active button { - background-color: #e9efef !important; - border-color: #678e8e !important; -} -.toolbar-sync.active.filters-tags a, -.toolbar-settings.active.filters-tags a, -.toolbar-audio.active.filters-tags a, -.toolbar-sync.active.filters-tags button, -.toolbar-settings.active.filters-tags button, -.toolbar-audio.active.filters-tags button { - background-color: #97b3b3 !important; - border-color: #f4f7f7 !important; - color: #fff !important; -} -.toolbar-sync.active.filters-tags a span, -.toolbar-settings.active.filters-tags a span, -.toolbar-audio.active.filters-tags a span, -.toolbar-sync.active.filters-tags button span, -.toolbar-settings.active.filters-tags button span, -.toolbar-audio.active.filters-tags button span { - color: #fff !important; -} -.toolbar-notifs > a, -.toolbar-notifs > button { - background-color: #f4f7f7 !important; -} -.toolbar-notifs > a:active, -.toolbar-notifs > button:active { - background-color: #506e6e !important; -} -@media screen and (min-width: 768px) { - .toolbar-notifs > a:hover, - .toolbar-notifs > button:hover { - background-color: #edf2f2 !important; - } -} -.toolbar-notifs > a, -.toolbar-notifs > button, -.toolbar-notifs > input, -.toolbar-notifs textarea { - color: #3e5555 !important; - border-color: #c6d6d6 !important; -} -.toolbar-notifs > input:hover, -.toolbar-notifs textarea:hover { - border-color: #97b3b3 !important; -} -.toolbar-notifs > input:focus, -.toolbar-notifs textarea:focus { - border-color: #4f6d6d !important; - outline: none; -} -.toolbar-notifs > input + button:focus, -.toolbar-notifs textarea + button:focus { - border-color: #97b3b3 !important; - background-color: #e2eaea !important; - outline: none; -} -.toolbar-notifs > input + button:active, -.toolbar-notifs textarea + button:active { - background-color: #c6d6d6 !important; -} -.toolbar-notifs > a:nth-of-type(2) { - border-left: 1px solid #e9efef !important; -} -@media screen and (min-width: 768px) { - .toolbar-notifs > div { - border: 1px solid #c6d6d6; - } -} -.toolbar-notifs > div h4 { - color: #4f6d6d; - background-color: #f4f7f7; - border-color: #c6d6d6; -} -.toolbar-notifs > div > ul:first-child:before, -.toolbar-notifs > div > div:first-child:before { - background-color: #fff; - border-color: #c6d6d6; -} -.toolbar-notifs > div h4:before { - background-color: #f4f7f7; - border-color: #c6d6d6; -} -.toolbar-notifs > div ul { - border-color: #f4f7f7; -} -.toolbar-notifs > div a, -.toolbar-notifs > div a:link, -.toolbar-notifs > div a:visited { - color: #3e5555 !important; -} -.toolbar-notifs .glyphicon { - color: #537272; -} -.toolbar-notifs .tooltip { - border: 0; -} -.toolbar-notifs.active a, -.toolbar-notifs.active button { - background-color: #e9efef !important; - border-color: #678e8e !important; -} -.toolbar-notifs.active.filters-tags a, -.toolbar-notifs.active.filters-tags button { - background-color: #97b3b3 !important; - border-color: #f4f7f7 !important; - color: #fff !important; -} -.toolbar-notifs.active.filters-tags a span, -.toolbar-notifs.active.filters-tags button span { - color: #fff !important; -} -.toolbar-notifs > a span.inactive { - opacity: 0.236 !important; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=24)" !important; - filter: alpha(opacity=24) !important; -} -.toolbar-notifs div { - padding-bottom: 1em; - width: 300px; -} -@media screen and (max-width: 767px) { - .toolbar-notifs div { - width: 96%; - } -} -.toolbar-notifs div .toolbar-notifs-notifs { - padding-bottom: 1em; -} -.toolbar-notifs div .toolbar-notifs-notifs li .glyphicon { - margin-right: 0.618em; - height: 100%; -} -.toolbar-notifs div .toolbar-notifs-notifs a:nth-of-type(1) { - width: 84%; -} -.toolbar-notifs div .toolbar-notifs-notifs a:nth-of-type(2) { - width: 16%; - text-align: center; -} -.toolbar-notifs div .toolbar-notifs-notifs a:nth-of-type(2) .glyphicon-remove-circle { - margin: 0px; -} -.toolbar-notifs-no-messages { - padding: 0.382em 1em; -} -.toolbar-controls .toolbar-controls-button > a, -.toolbar-controls .toolbar-controls-button > button { - background-color: #f8fafa !important; -} -.toolbar-controls .toolbar-controls-button > a:active, -.toolbar-controls .toolbar-controls-button > button:active { - background-color: #517171 !important; -} -@media screen and (min-width: 768px) { - .toolbar-controls .toolbar-controls-button > a:hover, - .toolbar-controls .toolbar-controls-button > button:hover { - background-color: #f1f5f5 !important; - } -} -.toolbar-controls .toolbar-controls-button > a, -.toolbar-controls .toolbar-controls-button > button, -.toolbar-controls .toolbar-controls-button > input, -.toolbar-controls .toolbar-controls-button textarea { - color: #3e5757 !important; - border-color: #c9d8d8 !important; -} -.toolbar-controls .toolbar-controls-button > input:hover, -.toolbar-controls .toolbar-controls-button textarea:hover { - border-color: #99b6b6 !important; -} -.toolbar-controls .toolbar-controls-button > input:focus, -.toolbar-controls .toolbar-controls-button textarea:focus { - border-color: #4f6f6f !important; - outline: none; -} -.toolbar-controls .toolbar-controls-button > input + button:focus, -.toolbar-controls .toolbar-controls-button textarea + button:focus { - border-color: #99b6b6 !important; - background-color: #e6eded !important; - outline: none; -} -.toolbar-controls .toolbar-controls-button > input + button:active, -.toolbar-controls .toolbar-controls-button textarea + button:active { - background-color: #c9d8d8 !important; -} -.toolbar-controls .toolbar-controls-button > a:nth-of-type(2) { - border-left: 1px solid #edf2f2 !important; -} -@media screen and (min-width: 768px) { - .toolbar-controls .toolbar-controls-button > div { - border: 1px solid #c9d8d8; - } -} -.toolbar-controls .toolbar-controls-button > div h4 { - color: #4f6f6f; - background-color: #f8fafa; - border-color: #c9d8d8; -} -.toolbar-controls .toolbar-controls-button > div > ul:first-child:before, -.toolbar-controls .toolbar-controls-button > div > div:first-child:before { - background-color: #fff; - border-color: #c9d8d8; -} -.toolbar-controls .toolbar-controls-button > div h4:before { - background-color: #f8fafa; - border-color: #c9d8d8; -} -.toolbar-controls .toolbar-controls-button > div ul { - border-color: #f8fafa; -} -.toolbar-controls .toolbar-controls-button > div a, -.toolbar-controls .toolbar-controls-button > div a:link, -.toolbar-controls .toolbar-controls-button > div a:visited { - color: #3e5757 !important; -} -.toolbar-controls .toolbar-controls-button .glyphicon { - color: #537474; -} -.toolbar-controls .toolbar-controls-button .tooltip { - border: 0; -} -.toolbar-controls .toolbar-controls-button.active a, -.toolbar-controls .toolbar-controls-button.active button { - background-color: #edf2f2 !important; - border-color: #689191 !important; -} -.toolbar-controls .toolbar-controls-button.active.filters-tags a, -.toolbar-controls .toolbar-controls-button.active.filters-tags button { - background-color: #99b6b6 !important; - border-color: #f8fafa !important; - color: #fff !important; -} -.toolbar-controls .toolbar-controls-button.active.filters-tags a span, -.toolbar-controls .toolbar-controls-button.active.filters-tags button span { - color: #fff !important; -} -@media screen and (max-width: 767px) { - .toolbar-toggle { - display: none; - } - .toolbar-mobile .toolbar-submenu, - .toolbar-settings .toolbar-submenu, - .toolbar-audio .toolbar-submenu { - padding-left: 1em; - display: block; - } - .toolbar-mobile .toolbar-submenu li, - .toolbar-settings .toolbar-submenu li, - .toolbar-audio .toolbar-submenu li { - display: inline-block; - } - .toolbar-mobile .toolbar-submenu li a, - .toolbar-settings .toolbar-submenu li a, - .toolbar-audio .toolbar-submenu li a { - padding-left: 0em; - } - .toolbar-subscribe { - display: none; - } -} -@-moz-keyframes toggle-close { - 0% { - -webkit-transform: rotate(0deg); - -moz-transform: rotate(0deg); - -o-transform: rotate(0deg); - -ms-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(45deg); - -moz-transform: rotate(45deg); - -o-transform: rotate(45deg); - -ms-transform: rotate(45deg); - transform: rotate(45deg); - } -} -@-webkit-keyframes toggle-close { - 0% { - -webkit-transform: rotate(0deg); - -moz-transform: rotate(0deg); - -o-transform: rotate(0deg); - -ms-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(45deg); - -moz-transform: rotate(45deg); - -o-transform: rotate(45deg); - -ms-transform: rotate(45deg); - transform: rotate(45deg); - } -} -@-o-keyframes toggle-close { - 0% { - -webkit-transform: rotate(0deg); - -moz-transform: rotate(0deg); - -o-transform: rotate(0deg); - -ms-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(45deg); - -moz-transform: rotate(45deg); - -o-transform: rotate(45deg); - -ms-transform: rotate(45deg); - transform: rotate(45deg); - } -} -@keyframes toggle-close { - 0% { - -webkit-transform: rotate(0deg); - -moz-transform: rotate(0deg); - -o-transform: rotate(0deg); - -ms-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(45deg); - -moz-transform: rotate(45deg); - -o-transform: rotate(45deg); - -ms-transform: rotate(45deg); - transform: rotate(45deg); - } -} -@-moz-keyframes toggle-open { - 0% { - -webkit-transform: rotate(45deg); - -moz-transform: rotate(45deg); - -o-transform: rotate(45deg); - -ms-transform: rotate(45deg); - transform: rotate(45deg); - } - 100% { - -webkit-transform: rotate(0deg); - -moz-transform: rotate(0deg); - -o-transform: rotate(0deg); - -ms-transform: rotate(0deg); - transform: rotate(0deg); - } -} -@-webkit-keyframes toggle-open { - 0% { - -webkit-transform: rotate(45deg); - -moz-transform: rotate(45deg); - -o-transform: rotate(45deg); - -ms-transform: rotate(45deg); - transform: rotate(45deg); - } - 100% { - -webkit-transform: rotate(0deg); - -moz-transform: rotate(0deg); - -o-transform: rotate(0deg); - -ms-transform: rotate(0deg); - transform: rotate(0deg); - } -} -@-o-keyframes toggle-open { - 0% { - -webkit-transform: rotate(45deg); - -moz-transform: rotate(45deg); - -o-transform: rotate(45deg); - -ms-transform: rotate(45deg); - transform: rotate(45deg); - } - 100% { - -webkit-transform: rotate(0deg); - -moz-transform: rotate(0deg); - -o-transform: rotate(0deg); - -ms-transform: rotate(0deg); - transform: rotate(0deg); - } -} -@keyframes toggle-open { - 0% { - -webkit-transform: rotate(45deg); - -moz-transform: rotate(45deg); - -o-transform: rotate(45deg); - -ms-transform: rotate(45deg); - transform: rotate(45deg); - } - 100% { - -webkit-transform: rotate(0deg); - -moz-transform: rotate(0deg); - -o-transform: rotate(0deg); - -ms-transform: rotate(0deg); - transform: rotate(0deg); - } -} -#main > div > div > div > .tab-content { - margin: 1.618em 0em; -} -@media screen and (min-width: 768px) { - #main > div > div > div > .tab-content { - border: 1px solid #d5e3e3; - padding-bottom: 1.618em; - } -} -.options-menu, -.options-submenu { - border-bottom: 1px solid #d5e3e3; -} -@media screen and (min-width: 768px) { - .options-menu { - background-color: #f9fbfb; - } -} -.options-menu li > a, -.options-menu li > button { - background-color: #f9fbfb !important; -} -.options-menu li > a:active, -.options-menu li > button:active { - background-color: #4e7474 !important; -} -@media screen and (min-width: 768px) { - .options-menu li > a:hover, - .options-menu li > button:hover { - background-color: #f2f6f6 !important; - } -} -.options-menu li > a, -.options-menu li > button, -.options-menu li > input, -.options-menu li textarea { - color: #3c5a5a !important; - border-color: #c8dbdb !important; -} -.options-menu li > input:hover, -.options-menu li textarea:hover { - border-color: #97b9b9 !important; -} -.options-menu li > input:focus, -.options-menu li textarea:focus { - border-color: #4c7373 !important; - outline: none; -} -.options-menu li > input + button:focus, -.options-menu li textarea + button:focus { - border-color: #97b9b9 !important; - background-color: #e6efef !important; - outline: none; -} -.options-menu li > input + button:active, -.options-menu li textarea + button:active { - background-color: #c8dbdb !important; -} -.options-menu li > a:nth-of-type(2) { - border-left: 1px solid #eef3f3 !important; -} -@media screen and (min-width: 768px) { - .options-menu li > div { - border: 1px solid #c8dbdb; - } -} -.options-menu li > div h4 { - color: #4c7373; - background-color: #f9fbfb; - border-color: #c8dbdb; -} -.options-menu li > div > ul:first-child:before, -.options-menu li > div > div:first-child:before { - background-color: #fff; - border-color: #c8dbdb; -} -.options-menu li > div h4:before { - background-color: #f9fbfb; - border-color: #c8dbdb; -} -.options-menu li > div ul { - border-color: #f9fbfb; -} -.options-menu li > div a, -.options-menu li > div a:link, -.options-menu li > div a:visited { - color: #3c5a5a !important; -} -.options-menu li .glyphicon { - color: #507878; -} -.options-menu li .tooltip { - border: 0; -} -.options-menu li.active a, -.options-menu li.active button { - background-color: #eef3f3 !important; - border-color: #649696 !important; -} -.options-menu li.active.filters-tags a, -.options-menu li.active.filters-tags button { - background-color: #97b9b9 !important; - border-color: #f9fbfb !important; - color: #fff !important; -} -.options-menu li.active.filters-tags a span, -.options-menu li.active.filters-tags button span { - color: #fff !important; -} -.options-submenu { - margin-top: -1.618em; - background-color: #f6f8f9; -} -.options-submenu li > a, -.options-submenu li > button { - background-color: #f6f8f9 !important; -} -.options-submenu li > a:active, -.options-submenu li > button:active { - background-color: #4d6673 !important; -} -@media screen and (min-width: 768px) { - .options-submenu li > a:hover, - .options-submenu li > button:hover { - background-color: #eff3f4 !important; - } -} -.options-submenu li > a, -.options-submenu li > button, -.options-submenu li > input, -.options-submenu li textarea { - color: #3b4f59 !important; - border-color: #c6d3d9 !important; -} -.options-submenu li > input:hover, -.options-submenu li textarea:hover { - border-color: #95acb8 !important; -} -.options-submenu li > input:focus, -.options-submenu li textarea:focus { - border-color: #4c6571 !important; - outline: none; -} -.options-submenu li > input + button:focus, -.options-submenu li textarea + button:focus { - border-color: #95acb8 !important; - background-color: #e4eaed !important; - outline: none; -} -.options-submenu li > input + button:active, -.options-submenu li textarea + button:active { - background-color: #c6d3d9 !important; -} -.options-submenu li > a:nth-of-type(2) { - border-left: 1px solid #ebeff1 !important; -} -@media screen and (min-width: 768px) { - .options-submenu li > div { - border: 1px solid #c6d3d9; - } -} -.options-submenu li > div h4 { - color: #4c6571; - background-color: #f6f8f9; - border-color: #c6d3d9; -} -.options-submenu li > div > ul:first-child:before, -.options-submenu li > div > div:first-child:before { - background-color: #fff; - border-color: #c6d3d9; -} -.options-submenu li > div h4:before { - background-color: #f6f8f9; - border-color: #c6d3d9; -} -.options-submenu li > div ul { - border-color: #f6f8f9; -} -.options-submenu li > div a, -.options-submenu li > div a:link, -.options-submenu li > div a:visited { - color: #3b4f59 !important; -} -.options-submenu li .glyphicon { - color: #4f6a77; -} -.options-submenu li .tooltip { - border: 0; -} -.options-submenu li.active a, -.options-submenu li.active button { - background-color: #ebeff1 !important; - border-color: #638494 !important; -} -.options-submenu li.active.filters-tags a, -.options-submenu li.active.filters-tags button { - background-color: #95acb8 !important; - border-color: #f6f8f9 !important; - color: #fff !important; -} -.options-submenu li.active.filters-tags a span, -.options-submenu li.active.filters-tags button span { - color: #fff !important; -} -.options-blurbmenu { - margin: -1.618em 0 1.618em; - border-bottom: 1px solid #d5e3e3; - padding: 0.5em; -} -@media screen and (max-width: 767px) { - .options-blurbmenu { - padding: 1em 0.618em 0 0.618em; - border: 1px solid #d5e3e3; - border-top: none; - } -} -noscript { - display: block; - padding: 20px; - text-align: center; -} -noscript p { - margin-top: 5px; -} -noscript p a { - display: block; - margin-top: 5px; -} -noscript.banner { - background-color: #fff9ca; - border: 1px solid #dfd8c2; -} -#loadingScreen { - z-index: 9999; - width: 100%; - height: 100%; - padding-top: 150px; - text-align: center; -} -#loadingScreen h2 { - margin-top: 60px; -} -#loadingScreen h3 { - margin-top: 20px; -} -@media (max-device-width: 768px) and (orientation: landscape) { - #loadingScreen { - padding-top: 75px; - } -} -@media (max-device-width: 768px) { - #loadingScreen h3 { - padding-left: 10px; - padding-right: 10px; - } - #loadingScreen h2 { - margin-top: 30px; - } -} -#loadingScreen img { - display: block; - margin: 0 auto; - width: 90%; -} -#loadingScreen .loading-logo-icon { - max-width: 87.5px; - margin-bottom: 15px; -} -#loadingScreen .loading-logo-text { - max-width: 282.5px; -} -.loading-spinner { - margin: 100px auto 0; - width: 105px; - padding-left: 5px; - text-align: center; - margin-top: 20px; -} -.loading-spinner > div { - width: 16px; - height: 16px; - background-color: #432476; - -webkit-border-radius: 100%; - border-radius: 100%; - display: inline-block; - -webkit-animation: sk-bouncedelay 1.7s infinite ease-in-out both; - -moz-animation: sk-bouncedelay 1.7s infinite ease-in-out both; - -o-animation: sk-bouncedelay 1.7s infinite ease-in-out both; - -ms-animation: sk-bouncedelay 1.7s infinite ease-in-out both; - animation: sk-bouncedelay 1.7s infinite ease-in-out both; - margin-right: 5px; -} -.loading-spinner .spinner__item1 { - -webkit-animation-delay: -0.6s; - -moz-animation-delay: -0.6s; - -o-animation-delay: -0.6s; - -ms-animation-delay: -0.6s; - animation-delay: -0.6s; -} -.loading-spinner .spinner__item2 { - -webkit-animation-delay: -0.4s; - -moz-animation-delay: -0.4s; - -o-animation-delay: -0.4s; - -ms-animation-delay: -0.4s; - animation-delay: -0.4s; -} -.loading-spinner .spinner__item3 { - -webkit-animation-delay: -0.2s; - -moz-animation-delay: -0.2s; - -o-animation-delay: -0.2s; - -ms-animation-delay: -0.2s; - animation-delay: -0.2s; -} -@-moz-keyframes sk-bouncedelay { - 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)"; - filter: alpha(opacity=0); - } - 40% { - -webkit-transform: scale(1); - -moz-transform: scale(1); - -o-transform: scale(1); - -ms-transform: scale(1); - transform: scale(1); - opacity: 1; - -ms-filter: none; - filter: none; - } -} -@-webkit-keyframes sk-bouncedelay { - 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)"; - filter: alpha(opacity=0); - } - 40% { - -webkit-transform: scale(1); - -moz-transform: scale(1); - -o-transform: scale(1); - -ms-transform: scale(1); - transform: scale(1); - opacity: 1; - -ms-filter: none; - filter: none; - } -} -@-o-keyframes sk-bouncedelay { - 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)"; - filter: alpha(opacity=0); - } - 40% { - -webkit-transform: scale(1); - -moz-transform: scale(1); - -o-transform: scale(1); - -ms-transform: scale(1); - transform: scale(1); - opacity: 1; - -ms-filter: none; - filter: none; - } -} -@keyframes sk-bouncedelay { - 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)"; - filter: alpha(opacity=0); - } - 40% { - -webkit-transform: scale(1); - -moz-transform: scale(1); - -o-transform: scale(1); - -ms-transform: scale(1); - transform: scale(1); - opacity: 1; - -ms-filter: none; - filter: none; - } -} -html, -body, -p, -h1, -ul, -li, -table, -tr, -th, -td { - margin: 0; - padding: 0; -} -* { - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; - font-family: "Lato", sans-serif; -} -hr { - border-top: 0; - border-bottom: 1px solid #ddd; - border-color: rgba(0,0,0,0.1); -} -/* Customizations to make footer sticky */ -html, -body { - height: 100%; -} -#wrap { - z-index: -1; - padding: 0 15px; - min-height: 100%; - height: auto !important; -} -@media (max-width: 600px) { - #wrap { - margin-top: 0; - padding: 0 4px; - overflow-x: hidden; - } -} -/* Gems --------------------------------------------------- */ -/* Adaptation of GH's social-count for Gems */ -.gem-cost { - border: 1px solid #d4d4d4; - font-size: 11px; - font-weight: bold; - padding: 6px 7px 5px; - line-height: 20px; - margin-left: 8px; - -webkit-border-radius: 3px; - border-radius: 3px; - color: #333; - background-color: #fafafa; - position: relative; -} -.gem-cost::before { - content: ""; - display: block; - width: 0; - height: 0; - border: 6px solid #d4d4d4; - border-color: transparent #d4d4d4 transparent transparent; - position: absolute; - right: 100%; - margin-right: 0; - top: 50%; - margin-top: -6px; -} -.gem-cost::after { - content: ""; - display: block; - width: 0; - height: 0; - border: 6px solid #fafafa; - border-color: transparent #fafafa transparent transparent; - position: absolute; - right: 100%; - margin-right: -1px; - top: 50%; - margin-top: -6px; -} -/* Misc --------------------------------------------------- */ -.new-stuff-modal h2, -.new-stuff-modal h3 { - font-weight: 700; - font-size: 14px; - margin-top: 10px; -} -.modal-fixed-height { - overflow-y: auto; - max-height: 400px; -} -.modal-body figure { - float: left; - padding: 15px; -} -.modal-body figure.herobox { - padding-right: 0px; - padding-left: 0px; -} -.death-modal { - margin: 10px; -} -a { - cursor: pointer; -} -a span.glyphicon { - color: #333; -} -a span.glyphicon.text-danger { - color: #a94442; -} -a.label { - color: #fff; -} -.label .glyphicon { - color: #fff; -} -.muted { - color: #999; -} -.btn { - margin-right: 5px; -} -.panel-heading .btn { - margin-top: -8px; -} -.vertical-align { - vertical-align: middle; -} -.inline-block { - display: inline-block; -} -.btn-group .btn { - margin-right: 0px; -} -.btn-wrap { - white-space: normal; -} -.modal-indented-list { - margin-left: 10px; - padding-left: 10px; -} -.buy-gems .nav-tabs { - margin-top: 0.5em; -} -.buy-gems .gem-wallet { - padding: 0; - cursor: default; -} -.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); -} -.badge-info { - background-color: #428bca; -} -.badge-success { - background-color: #47a447; -} -.white, -.white a { - color: #fff !important; -} -.line-through { - text-decoration: line-through; -} -.markdown-preview markdown code { - white-space: inherit; -} -* { - image-rendering: pixelated; -} -.img-rendering-auto, -.img-rendering-auto *, -.habitica-emoji { - image-rendering: auto; -} diff --git a/www/build/static.css b/www/build/static.css deleted file mode 100644 index 70f4de911..000000000 --- a/www/build/static.css +++ /dev/null @@ -1,347 +0,0 @@ -/*! - * angular-loading-bar v0.6.0 - * https://chieffancypants.github.io/angular-loading-bar - * Copyright (c) 2014 Wes Cruver - * License: MIT - */ - -/* Make clicks pass-through */ -#loading-bar, -#loading-bar-spinner { - pointer-events: none; - -webkit-pointer-events: none; - -webkit-transition: 350ms linear all; - -moz-transition: 350ms linear all; - -o-transition: 350ms linear all; - transition: 350ms linear all; -} - -#loading-bar.ng-enter, -#loading-bar.ng-leave.ng-leave-active, -#loading-bar-spinner.ng-enter, -#loading-bar-spinner.ng-leave.ng-leave-active { - opacity: 0; -} - -#loading-bar.ng-enter.ng-enter-active, -#loading-bar.ng-leave, -#loading-bar-spinner.ng-enter.ng-enter-active, -#loading-bar-spinner.ng-leave { - opacity: 1; -} - -#loading-bar .bar { - -webkit-transition: width 350ms; - -moz-transition: width 350ms; - -o-transition: width 350ms; - transition: width 350ms; - - background: #29d; - position: fixed; - z-index: 10002; - top: 0; - left: 0; - width: 100%; - height: 2px; - border-bottom-right-radius: 1px; - border-top-right-radius: 1px; -} - -/* Fancy blur effect */ -#loading-bar .peg { - position: absolute; - width: 70px; - right: 0; - top: 0; - height: 2px; - opacity: .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; - box-shadow: #29d 1px 0 6px 1px; - -moz-border-radius: 100%; - -webkit-border-radius: 100%; - border-radius: 100%; -} - -#loading-bar-spinner { - display: block; - position: fixed; - z-index: 10002; - top: 10px; - left: 10px; -} - -#loading-bar-spinner .spinner-icon { - width: 14px; - height: 14px; - - 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; -} - -@-webkit-keyframes loading-bar-spinner { - 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); } -} -@-o-keyframes loading-bar-spinner { - 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); } -} -@keyframes loading-bar-spinner { - 0% { transform: rotate(0deg); transform: rotate(0deg); } - 100% { transform: rotate(360deg); transform: rotate(360deg); } -} - -.subscription-features tr td { - vertical-align: middle; - text-align: center; -} -.subscription-features tr th { - max-width: 150px; - font-weight: normal; -} -.subscription-features tr th.feature-name { - text-align: center; - font-weight: bold; -} -.dashed-underline { - border-bottom: 1px dashed #000; - text-decoration: none; - cursor: pointer; -} -.buy-gems .form-inline .form-control { - padding: 6px; -} -p.resubscribe { - margin-top: 20px; -} -.buy-gems a.purchase { - height: 25px; - line-height: 12px; - font-size: 12px; - margin: 5px; -} -p.resubscribe a.purchase { - display: block; -} -.buy-gems button.customize-option { - position: relative; -} -.buy-gems .stack-count { - position: absolute; - bottom: -6px; - right: -9px; -} -.buy-gems .gem-count-popover { - display: block; -} -.buy-gems h3 { - margin-top: 0px; -} -.buy-gems .well { - margin-top: 15px; -} -.noninteractive-button { - padding: 0.5em; - margin-right: 0.5em; -} -.nav, -.pagination, -.carousel, -.panel-title a { - cursor: pointer; -} -.nav-tabs { - margin-bottom: 15px; -} -img { - max-width: 100%; -} -.hint { - border-bottom: 1px dotted #099; - text-decoration: none; - cursor: help; -} -a.hint { - border-bottom: 1px dotted #099; - text-decoration: none; - cursor: pointer; -} -a.hint:hover { - text-decoration: none; -} -.vertical-20 { - vertical-align: 20%; -} -.popover hr { - margin: 10px 0; -} -ul { - list-style-position: inside; -} -li.spaced { - margin: auto auto 0.5em 3em; -} -.group-leave-join .glyphicon-ban-circle { - color: #fff; - margin-right: 5px; -} -@media (max-width: 767px) { - .container-fluid { - padding-right: 5px; - padding-left: 5px; - } - .container-fluid > .row { - margin-left: -5px; - margin-right: -5px; - } - .tasks-lists .row .col-md-3 { - padding-right: 0; - padding-left: 0; - } -} -.flex-column { - display: -webkit-box; - display: -moz-box; - display: -webkit-flex; - display: -ms-flexbox; - display: box; - display: flex; - -webkit-box-orient: vertical; - -moz-box-orient: vertical; - -o-box-orient: vertical; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; -} -.row-margin { - margin: auto auto 1em auto; -} -.reduce-top-margin { - margin-top: -10px; -} -.slight-right-margin { - margin-right: 1em; -} -.footer { - padding-top: 20px; - margin-top: 40px; - padding-bottom: 20px; - border-top: 1px solid #e5e5e5; - background-color: #f5f5f5; -} -body { - margin-top: 60px; -} -#aboutPage iframe { - margin-bottom: 20px; -} -.muted i, -i.muted { - opacity: 0.5; - -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=50)"; - filter: alpha(opacity=50); -} -#header-play-button { - margin-right: 15px; -} -#tagline { - margin: 10px 0 20px 0; - font-size: 40px; -} -.marketing { - text-align: center; -} -.static-old-news h2 { - font-weight: 700; - font-size: 16px; - margin-top: 30px; - text-transform: uppercase; -} -.static-old-news h3 { - font-weight: 700; - font-size: 14px; - margin-top: 20px; -} -.static-old-news .pull-right { - margin-left: 5px; -} -#frontpage-play-button { - display: inline; - font-size: 20px; - min-width: 100px; - height: 50px; -} -#about-page img { - -webkit-box-shadow: 0px 0px 10px 5px #888; - box-shadow: 0px 0px 10px 5px #888; - margin: 10px; - max-width: 500px; -} -a.h2.accordion { - display: block; - text-decoration: none; -} -a.h2.accordion:before { - font-family: 'Glyphicons Halflings'; - color: #808080; - content: "\e114"; - margin-right: 0.5em; -} -a.h2.accordion.collapsed:before { - font-family: 'Glyphicons Halflings'; - color: #808080; - content: "\e080"; - margin-right: 0.5em; -} -.merch-block { - border: 1px dotted #553889; - -webkit-border-radius: 6px; - border-radius: 6px; - padding: 15px; - margin-bottom: 20px; -} -.merch-block:hover { - background: #f5f5f5; -} -.merch-block div { - width: 100%; - text-align: center; - padding: 30px 0; -} -.merch-block div a img { - display: inline; - max-width: 350px; -} -.merch-block img { - display: block; - max-width: 200px; -} -.merch-block .btn { - width: 100%; - background-color: #553889; - margin-top: 20px; - color: #fff; -} -.merch-block .btn:hover { - color: #fff; - background-color: #452d6e; - border-color: #342253; -} diff --git a/www/css/main.diary.css b/www/css/main.diary.css deleted file mode 100644 index 474d57256..000000000 --- a/www/css/main.diary.css +++ /dev/null @@ -1,522 +0,0 @@ -.no-margin { - margin:0px !important; - padding:0px !important; -} - -.item { - 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; - min-height: 100%; -} - -.row { - padding:0px; -} - -.col { - padding:0px !important; -} - -.small { - font-size:7px; -} -.bg-color{ - background:#71bc98!important; - color:whitesmoke !important; -} -.summary-color{ - background:#1b9e77!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%; } -} - -/* ----------- 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%; } -} - -/* ----------- 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%; } -} - - -.list .item.item-accordion { - - transition: 0.09s all linear; -} -.list .item.item-accordion.ng-hide { - line-height: 0px; -} -.list .item.item-accordion.ng-hide-add, -.list .item.item-accordion.ng-hide-remove { - display: block !important; -} -a.item-content { - padding: 0 !important; - background-color: transparent !important; -} -.inner-icon { - background-color: white; - width: 6px; - height: 6px; - border-radius: 3px; - margin-left: 3px; - margin-top: 3px; -} -.button { - min-height: 10px; /* Changed from 47px. This allows for the correct height for the label buttons to fit nicely in the diary card. */ - line-height: normal; -} - -.diary-btn { - width: 100%; - padding-inline: 14px; - min-height: 28px; - max-height: 32px; - margin: 2px; - box-sizing: border-box; - border-radius: 50px; - 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); - background-color: white; - color: var(--accent-dark); - border: .12em solid var(--accent); -} - -.diary-btn-yellow, -.diary-btn-yellow:hover, -.diary-btn-yellow:active { - background-color: #FFC108; /* tentatively orange for now */ - color: white; - border: 2px solid rgba(0, 136, 206, 0.2); -} -.diary-btn-blue, -.diary-btn-blue:hover, -.diary-btn-blue:active { - background-color: var(--accent); - color: white; - border: 2px solid rgba(0, 136, 206, 0.2); -} - -/* icon */ -.diary-btn:before { - font-weight: bold; - scale: 1.7; - margin-right: .6em; - line-height: 100%; -} - -.input-confirm-label { - text-align: center; - font-size: 14px; - font-weight: bold; - color: var(--accent); -} - -.input-confirm-container { - margin-top: 5px; -} - -.popover { - height: 297px; - width: 230px; -} - -.popover .scroll-content { - height: auto; - margin: 0 !important; - border-radius: 0 0 10px 10px !important; -} - -.big-popover { - height: 40%; - width: 75%; -} - -#diary-item { - padding: 0; - border-width: 0; -} - -.unified-diary-item { - overflow: visible; - padding: 15px 6px; - margin: 0 10px; - border-width: 0; - background-color: transparent; - height: min-content; - /* height: 250px; */ -} - -.diary-card { - background: white; - width: 100%; - height: min-content; - font-size: 13px; - line-height: 1.2; - margin: 0; - border: 1px solid rgb(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%); - display: flex; - flex-wrap: wrap; - background: linear-gradient(40deg, - hsla(200, 30%, 97%, 1) 40%, - hsla(0, 0%, 100%, 1)), -} - -.diary-card.place, .diary-card.untracked { - color: #222; - background: hsl(200 100% 85%); - border: 1px solid hsl(200 100% 10% / 0.2); - margin: 6px auto; - text-align: center; -} - -.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); -} - -.diary-card.untracked .card-title b { - font-size: 13px; - color: #222; - padding: 0 4px; - background-color: var(--accent-light); - border-radius: 5px; -} - -.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); - /* 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); -} - -.card-title { - display: flex; - justify-content: center; - font-size: 15px; - padding-inline: 10px; -} - -.card-title b { - margin-left: 10px; - text-overflow: ellipsis; - overflow: hidden; -} - -.card-date { - display: block; - white-space: break-spaces; - text-decoration: underline; -} - -.diary-button { - width: 100%; - height: 30px; - margin: 2px; - border: 2px solid var(--accent); - box-sizing: border-box; - border-radius: 50px; - background-color: #ffffff; - font-style: normal; - font-weight: normal; - font-size: 13px; -} - -.hr-lines { - position: relative; - justify-content: center; - font-weight: bold; - display: flex; - color: #212121; - font-size: 19px; - margin-left: 10px; - margin-right: 10px; - margin-bottom: 5px; - margin-top: 5px; - margin: 10px auto; - text-align: center; -} - -@media screen and (orientation: portrait) { - .hr-lines:before { - content: " "; - display: block; - height: 2px; - width: 30%; - position: absolute; - top: 50%; - left: 0; - background: var(--accent-light); - } - - .hr-lines:after { - content: " "; - display: block; - height: 2px; - width: 30%; - position: absolute; - top: 50%; - right: 0; - background: var(--accent-light); - } -} -@media screen and (orientation: landscape) { - .hr-lines:before { - content: " "; - display: block; - height: 2px; - width: 41%; - position: absolute; - top: 50%; - left: 0; - background: var(--accent-light); - } - - .hr-lines:after { - content: " "; - display: block; - height: 2px; - width: 41%; - position: absolute; - top: 50%; - right: 0; - background: var(--accent-light); - } -} - -.diary-route { - margin: 5px 0; - height: 85px; -} - -.diary-street { - font-style: normal; - font-weight: normal; - font-size: 13px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.diary-street .icon { - color: var(--accent-light); -} -.diary-street + .diary-street .icon { - color: var(--accent); -} -.two-lines { - -webkit-line-clamp: 2; - display: -webkit-box; - -webkit-box-orient: vertical; - white-space: normal; - overflow: hidden; -} -.btn-input-wrap { - white-space: normal; - overflow: hidden; - text-overflow: ellipsis; -} - -.diary-map > div { - overflow: hidden; - border-radius: 30px 0px 0px 30px; - z-index: 0; -} - -.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{ - border-radius: 30px 0px 30px 0px; -} - -.diary-map-shell { - width: 50%; - max-height: 100%; - display: grid; - grid-template-rows: 1fr; -} - -.enhanced-trip-item .diary-map-shell { - grid-template-rows: 4fr 1fr; -} - -.diary-infos { - float: right; - width: 50%; - padding: 2% 2%; - display: flex; - flex-direction: column; - justify-content: space-between; - height: 50%; -} - -/* to center the enketo/multilabel button */ -.diary-infos:nth-child(2) { - justify-content: center; -} - -.diary-distance-time { - font-style: normal; - font-weight: bold; - font-size: 16px; - white-space: normal; - text-align: center; - margin-left: auto; - margin-right: auto; - height: 40px; -} - -.diary-modes-percents { - position: absolute; - z-index: 1; - left: 50%; - translate: -50%; - background: rgba(255, 255, 255, 0.8); - border-radius: 50px; - display: flex; - gap: 8px; - font-size: 12px; - margin: 5px 0; - padding: 1px 5px; -} - -.diary-modes-percents-text { - color: var(--accent); -} - -/* Purpose selection Override */ -.two-lines .item-content { - overflow: hidden; - text-overflow: ellipsis; - -webkit-line-clamp: 2; - display: -webkit-box; - -webkit-box-orient: vertical; - white-space: normal; - max-height: 65px; -} - -/* Date Picker Override */ -.ionic_datepicker_popup .today { - border: 1px solid var(--accent) !important; -} - -.ionic_datepicker_popup .selected_date { - background-color: var(--accent) !important; -} - -.ionic_datepicker_popup .popup-head { - background-color: var(--accent) !important; -} - -.ionic_datepicker_popup .popup-body .selected_date_full { - background-color: var(--accent) !important; -} - -.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 { - color: var(--accent) !important; -} - -.ionic_datepicker_popup .popup-body .button-clear { - color: var(--accent) !important; -} - -.ionic_datepicker_popup .popup-buttons button { - background-color: var(--accent) !important; -} -div.labelfilterlist { - margin: 0 !important; -} - -.button.labelfilter { - 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); - padding: 0 0.1em !important; -} - -.button.labelfilter.on { - background-color: var(--accent); - color: white; -} - -.labelfilter:first-of-type { - border-top-left-radius: 5px; - border-bottom-left-radius: 5px; -} - -.labelfilter:last-of-type { - border-top-right-radius: 5px; - border-bottom-right-radius: 5px; -} diff --git a/www/css/style.css b/www/css/style.css deleted file mode 100644 index dea003e7b..000000000 --- a/www/css/style.css +++ /dev/null @@ -1,1462 +0,0 @@ -/* Scoped styles for Enketo */ -/* if we don't contain them here, they will leak into the rest of the app */ -.enketo-plugin { - @import 'enketo-core/src/sass/formhub/formhub.scss'; - flex: 1; - .question.non-select { - display: inline-block; - } - .question input[name*="_date"], - .question input[name*="_time"] { - width: calc(40vw - 10px); - margin-right: 5px; - display: flex; - } -} - -.enketo-plugin .form-header { - max-height: 50px; -} - -.fill-container { - display: block; - position: relative; -} - -.fill-container > div[class*='css-'] { - height: 100%; - width: 100%; - position: absolute; -} - -/* Without this, the LabelTab does not fill the entire height of the screen. - It has something to do with React Navigation's NavigationContainer. - This should not be necessary once the entire app's routing has been - converted to use React Navigation */ -label-tab > div { - height: 100%; -} - -/* default color pallete (may be overridden by child elements) */ -:root { - --accent: hsl(200, 100%, 40%); - --accent-light: hsl(200, 100%, 75%); - --accent-dark: hsl(200 100% 30%); -} - -.view-container.tab-content { - height: auto !important; - bottom: 50px !important; - top: 0px !important; -} - -.has-tabs { - bottom: 0 !important; -} - -.platform-ios .view-container { - height: calc(100% - (env(safe-area-inset-top) / 2)); -} - -.join-inline-info { - padding-bottom: 5%; - padding-right: 0%; - padding-left: 0%; - font-size: 13px; - width: 100%; - text-align: center; -} - -[ng\:cloak], [ng-cloak], .ng-cloak { - display: none !important; -} - -.popup-title { - color: #6e6e6e; -} - -.pull-right { - float: right -} -.pull-left { - float: left -} - -.button.button-icon.ion-help:before { - font-size: 25px; -} - -.popup-buttons.row { - 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); - height: 40px; -} -.button.ng-binding.button-assertive { - background-color: var(--accent); - height: 40px; - -} -.button.ng-binding.button-cancel { - background-color: #d02001; - height: 40px; - color: #ffffff -} -.selected_date_full.ng-binding { - color: var(--accent); -} -.icon.ion-chevron-left { - color: var(--accent); -} -.icon.ion-chevron-right { - color: var(--accent); -} -.date_col.date_selected { - background-color: var(--accent) !important; - -} -.date_col:active { - background-color: var(--accent) !important; -} -.customButtomIconSize:before { - font-size: 25px !important; -} - -#dashboard-footprint.card { - background: #fff; - color: var(--accent); - border-radius: 5px; - display: block; - 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); - /*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))); - background: -webkit-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: -o-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: -ms-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: linear-gradient(135deg, 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%); - filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#28dab7', endColorstr='#7ffafa', GradientType=1 );*/ - text-align: center; - transition: height 0.3s ease-out; - overflow: hidden; -} - -.small-footprint-card{ - height: 140px !important; -} - -.expanded-footprint-card{ - height: 460px !important; -} - -#dashboard-calorie.card { - background: #fff; - color: var(--accent-light); - border-radius: 5px; - 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); - /*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))); - background: -webkit-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: -o-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: -ms-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: linear-gradient(135deg, 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%); - filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#28dab7', endColorstr='#7ffafa', GradientType=1 );*/ - text-align: center; - transition: height 0.3s ease-out; - overflow: hidden; -} - -.small-calorie-card{ - height: 140px !important; -} - -.expanded-calorie-card{ - height: 370px !important; -} - -/*#dashboard.card.inactive:after{ - content: ""; - position: absolute; - bottom:0px; - left:0; - height: 200px; - display: block; -} - -#dashboard.card.active{ - max-height: 500px; - transition: max-height 0.3s ease-out; -}*/ - -#dashboard-bottom.card { - background-color: #fff; - color: var(--accent); - border-radius: 5px; - display: block; - /* height: 140px; */ - margin: 10px; - 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); - /*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))); - background: -webkit-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: -o-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: -ms-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: linear-gradient(135deg, 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%); - filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#28dab7', endColorstr='#7ffafa', GradientType=1 );*/ - text-align: center; -} - -#arrow-color{ - color: var(--accent); - font-size: 25px !important; -} - -h4.dashboard-headers{ - color: #fff; - background: var(--accent); - padding-top: 5px; - padding-bottom: 5px; - margin-top: -1px; - font-weight: 700; - font-size: 20px; - margin-bottom: 0px !important; -} - -.user-carbon-no-percentage{ - padding-top: 30px; - position: absolute; - width: 100%; -} - -.user-carbon-percentage{ - padding-top: 10px; - position: absolute; - width: 100%; -} - -.user-carbon{ - font-weight: 700; - color: var(--accent); - font-size: 16px; -} - -.user-calorie-no-percentage{ - padding-top: 30px; - position: absolute; - width: 100%; -} - -.user-calorie-percentage{ - padding-top: 10px; - position: absolute; - width: 100%; -} - -.user-calorie{ - font-weight: 700; - color: var(--accent); - font-size: 18px; -} - -.percentage-change{ - font-weight: 700; - color: var(--accent); - margin-bottom: 20px; -} - -.calorie-change{ - padding-top: 5px; - font-weight: 700; - color: var(--accent); -} - -.dashboard-list{ - padding-top: 10px; - font-weight: 700; - color: #fff; - border-radius: 5px !important; - height: 40px !important; - margin-bottom: 3px !important; - margin-top: 3px !important; - width: 90%; - background-color: var(--accent) !important; - box-shadow: 0 0px 0px rgba(0, 0, 0, 0.3) !important; -} - -.circle { - 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); - -moz-border-radius: 50px; - -webkit-border-radius: 50px; - border-radius: 50px; - float: left; - position: absolute; - margin-left: 15px; - margin-top: 10px; -} - -#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; - float: none; - display: inline-block; - margin-right: 20px; - margin-top: 20px; -} - -#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; -} - -#green-leaf{ - color: var(--accent-light); - font-size: 45px; - padding-top: 5px; -} - -#food{ - width: 45px; - padding-top: 7px; -} - -#foodB{ - width: 45px; - padding-top: 7px; - padding-right: 4px; -} - -.arrow-position{ - position: absolute; - bottom: 5px; - right: 10px; - color:#b2b2b2; - font-size: 20px; -} - -/* #modes.slider{ - height:245px !important; -} */ - -#modes.slider-slide{ - padding-top: 0 !important; - background-color:transparent; -} - -/*.ion-view-background-dashboard{ - background: rgba(135,245,245,1); - background: -moz-linear-gradient(top, rgba(135,245,245,1) 0%, rgba(120,245,235,1) 15%, rgba(108,245,234,1) 31%, rgba(140,250,232,1) 49%, rgba(118,247,219,1) 70%, rgba(95,250,216,1) 100%); - background: -webkit-gradient(left top, left bottom, color-stop(0%, rgba(135,245,245,1)), color-stop(15%, rgba(120,245,235,1)), color-stop(31%, rgba(108,245,234,1)), color-stop(49%, rgba(140,250,232,1)), color-stop(70%, rgba(118,247,219,1)), color-stop(100%, rgba(95,250,216,1))); - background: -webkit-linear-gradient(top, rgba(135,245,245,1) 0%, rgba(120,245,235,1) 15%, rgba(108,245,234,1) 31%, rgba(140,250,232,1) 49%, rgba(118,247,219,1) 70%, rgba(95,250,216,1) 100%); - background: -o-linear-gradient(top, rgba(135,245,245,1) 0%, rgba(120,245,235,1) 15%, rgba(108,245,234,1) 31%, rgba(140,250,232,1) 49%, rgba(118,247,219,1) 70%, rgba(95,250,216,1) 100%); - background: -ms-linear-gradient(top, rgba(135,245,245,1) 0%, rgba(120,245,235,1) 15%, rgba(108,245,234,1) 31%, rgba(140,250,232,1) 49%, rgba(118,247,219,1) 70%, rgba(95,250,216,1) 100%); - background: linear-gradient(to bottom, rgba(135,245,245,1) 0%, rgba(120,245,235,1) 15%, rgba(108,245,234,1) 31%, rgba(140,250,232,1) 49%, rgba(118,247,219,1) 70%, rgba(95,250,216,1) 100%); - 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; } - -.list .item.item-accordion { - line-height: 38px; - padding-top: 0; - padding-bottom: 0; - transition: 0.09s all linear; -} -.list .item.item-accordion.ng-hide { - line-height: 0px; -} -.list .item.item-accordion.ng-hide-add, -.list .item.item-accordion.ng-hide-remove { - display: block !important; -} -.input-label { - font-size: 16px; -} -.list { - padding-top: 0.5px !important; -} -.card { - background: #fff; - border-radius: 2px; - display: inline-block; - margin: 1rem; - position: relative; -} - -.load-more-btn { - background: white; - border-radius: 50px; - color: #222; - border: 1px solid rgb(0 0 0 / .2); - padding: 3px 20px; - margin: auto; - display: block; -} - -/* Light theme */ -.control-icon-button{ - text-align: center; - max-height: 56px; - background-color: #6c757d; - color: #fff; - padding-top: 16px; - width: 64px; - font-size:20px; -} - -.diary-button{ - text-align: center; - float: right; - height: 48px; - background-color: #a0a0a0; - /* background-color: #D6C780; */ - color: #fff; - padding-top: 16px; - width: 48px; /* Changed to fit the diary card in full view */ -} - -.control-version-number{ - text-align: center; - float: right; - height: 100%; - color: rgb(114, 114, 114); - padding-top: 16px; - width: 64px; - - font-size:20px; -} - -#switch-user.control-icon-button{ - background-color: #dc3545 !important; -} - -.gray-icon.control-icon-button{ - background-color: #CCCCCC !important; -} - -.toggle-on-ourcolor-bg{ - border-color: var(--accent) !important; - background-color: var(--accent) !important; -} - -.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); -} -.platform-ios.platform-cordova:not(.fullscreen) .bar-header:not(.bar-subheader) > * { - margin-top: 15px; -} -.platform-ios.platform-cordova:not(.fullscreen) .bar-header:not(.bar-subheader) { - height: 58px; -} -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%); -} -ion-nav-view { - z-index: 10; -} -/* this controls bacground color of dashboard and profile - label screen now styled in react*/ -.pane { - background-color: #edf1f6 !important; -} -.tabs-custom > .tabs, -.tabs.tabs-custom { - 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; } - -.tabs-striped.tabs-custom .tabs { - 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; } -.title.title-center.header-item { - color: #303030; -} -.title.title-center.header-item:empty { - 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; -} - -.button.date-picker-button { - font-size: 15px; -} - -.date-picker-arrow { - color: #303030 !important; margin-top: 4px; background-color: transparent !important; -} -/* Light theme ends */ - - -.earlier-later-expand { - color: #303030; - margin: 16px 16px 0 6px; - font-size: 0.8em; - opacity: 0.7; -} -.list-location { - color: #303030; - background-color: transparent; - font-size: 0.8em; - padding-top: 5px; - padding-bottom: 5px; - padding-left: 30px; - margin-top: 0 !important; - margin-bottom: 0!important; -} -p.list-text { - color: #303030; -} -a.list-text { - color: #303030; -} -.tab-item .icon { - margin: 3px auto 1px auto; - font-size: 25px; - height: 25px; -} - -.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); -} - -.card-1:hover { - 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); -} - -.card-3 { - 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); -} - -.card-5 { - box-shadow: 0 19px 38px rgba(0,0,0,0.30), 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; -} -.nav-bar-title { - color: #303030; opacity: 0.7; -} -/* Profile tab */ -.control-list-item { - background: #fff; - display: flex; - min-height: 50px; - margin-top: 0.5px; - justify-content: space-between; -} -.control-list-text { - padding-inline: 15px; - margin-block: auto; - max-width: calc(100% - 64px); - overflow: hidden; - display: -webkit-box; - line-height: 1.1; - -webkit-line-clamp: 5; /* number of lines to show */ - line-clamp: 5; - -webkit-box-orient: vertical; - text-overflow: ellipsis; -} -.control-list-toggle { - float: right; margin-top: 5px; margin-right: 2px; -} -/* Diary list tab */ - -.lightrail { - color: blue -} -.dev-zone-input { - padding: 7px 0; font-size: 16px; line-height: 22px; height: 36px; -} -.dev-zone-title { - padding: 18px 16px; -} -.dev-zone-button { - position: absolute; - right: 16px; - top: 16px; -} -.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); - border: 1px solid #ccc; -} -.bg-light { - background-color: #ffffff; -} -.bg-unprocessed { - background-color: #9eb2aa; -} -.list-card-sm { - width: 95%; -} -.list-card-md { - width: 95%; -} -.list-card-lg { - 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; -} -.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; -} -.list-col-right { - text-align: center; padding: 0.25em 0.8em; border-bottom-color: #ccc; border-bottom-width: 0.5px; border-bottom-style: solid; -} - -timestamp-badge { - position: absolute; - bottom: 0; - left: 50%; - transform: translate(-50%, 50%); - z-index: 999; -} - -timestamp-badge[light-bg] { - top: 0; - bottom: unset; - transform: translate(-50%, -50%); -} - -.start-end-addresses-container { - display: inline-block; - width: calc(100% - 25px); -} -.diary-more-container i { - font-size: 32px; -} -.diary-checkmark-container i { - font-size: 24px; - padding: 3px; -} - -.diary-checkmark-container i.can-verify { - color: #30A64A; - background-color: #ddd; - border-radius: 5px; -} -.diary-checkmark-container i.cannot-verify { - color: #E6B8B8; -} -.diary-checkmark-container i.already-verified { - color: #B8E6C2; -} -/* .diary-checkmark-container i.already-verified, .diary-checkmark-container i.cannot-verify { - color: #BFBFBF; -} */ - -.center-vert { - display: flex; - align-items: center; -} - -.center-horiz { - display: flex; - justify-content: center; -} - -.side-menu-item { - padding: 5% 10%; -} -.menu-item:active { - background-color: #dddfe2; -} - -.side-menu-item { - padding: 5% 10%; -} - -.metric-datepicker { - /*height: 33px;*/ - 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 */ - 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); - color: var(--accent); - height: 35px; -} -.metric-title { - height: 35px; - 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 */ - padding-left: 10px; -} - -.metric-datepicker-container-upper { - width: 100%; - float: left; -} -.metric-weekday-container-upper { - width: 28%; - 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); - color: var(--accent); - height: 35px; -} -.metric-weekday-container-lower { - width: 28%; - float: right; - 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); - color: var(--accent); - height: 35px; -} -.metric-datepicker-container-lower { - top: 40px; - width: 100%; - float: left; -} -.metric-left { - width: 25%; - float: left; -} -.metric-right { - text-align: center; - width: 75%; - float: right; -} -.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); - color: var(--accent); - font-weight: 700; - height: 30px; -} -.metric-change-data-button:active{ - background-color: var(--accent); - color: white; - box-shadow: none; -} -.metric-metric-button { - float: left; - 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); - color: var(--accent); - height: 35px; -} -.heatmap-mode-button { - float: left; - 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); - color: var(--accent); - height: 35px; - -} - -.current-mode-button { - border: none; - background-color:#2D9CDB; - display:inline-block; - cursor:pointer; - color:#ffffff; - opacity: 0.4; - font-size:28px; - width: 100%; - text-decoration:none; - height: 80px; - z-index: 1; - position: relative; - top: 65px; -} - -#current-start-time { - z-index: 1; - position: absolute; - top: 5%; - display: block; - width: 40%; - height: 25px; - background-color: #f5f5f5;; - border-radius: 10px; - color: #6A6A6A; - left: 30%; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 1px 2px rgba(0, 0, 0, 0.05); -} - -#current-start-time-text { - font-size: 15px; - text-align: center; - margin-top: 2px; -} - -#speed-and-direction { - z-index: 1; - position: absolute; - top: 2%; - right: 2%; - text-align: center; -} - -#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); - opacity: 0.9; - color: white; - width: 60px; - text-align: center; - height: 60px; - border-style: solid; - border-radius: 50%; - border-color: #6A6A6A; - border-width: 4px; -} - -#speed-value { - font-size: 25px; - font-weight: 600; - margin-top: 10px; -} - -#speed-unit { - font-size: 12px; - margin-top: -3px; -} - -#current-direction-text { - color:#6A6A6A; - font-size: 20px; - font-weight: 600; - margin-top: 5px; - opacity: 0.9; -} - -#slider_background { - z-index: 2; - position: absolute; - left: -4px; -} - -.report-button { - border-radius: 10px; - border: none; - 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); - 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; -} - -.metric-freq-button { - float: right; - 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); - color: var(--accent); - height: 35px; -} -.metric-get-button { - width: 60px; - border-radius: 5px; - color: white; - background-color: var(--accent); - height: 35px; -} -.metric-get-button-inactive { - width: 60px; - border-radius: 5px; - color: white; - background-color: var(--accent); - opacity: 0.7; - height: 35px; -} -.hvcenter { - 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 */ -} -.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); - color: var(--accent); - height: 35px; -} -.metric-half { - 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; - height: 30px; - overflow: hidden; - position: relative; -} - -/*Change later*/ -.metric-second-half { - width: 90%; - margin: auto; -} -.metric-current-title { - color: #333; - position: absolute; - width: 28%; - height: 35px; -} -.metric-current-title-nofilter { - position: absolute; - color: white; - background-color: var(--accent); - text-align: center; - width: 100%; - height: 35px; -} -.metric-range-button { - border-left-color: #ccc; - border-left-style: solid; - border-left-width: 1px; - position: absolute; - border-right-color: #ccc; - border-right-style: solid; - border-right-width: 1px; - width: 25%; - left: 50%; - height: 35px; -} -.metric-range-button-active { - border-left-color: #ccc; - border-left-style: solid; - border-left-width: 1px; - position: absolute; - border-right-color: #ccc; - border-right-style: solid; - border-right-width: 1px; - width: 25%; - left: 50%; - height: 35px; - background-color: var(--accent); - color: white; -} -.metric-filter-button { - position: absolute; - width: 25%; - left: 75%; - height: 35px; - border-top-right-radius: 5px; - border-bottom-right-radius: 5px; -} -.metric-filter-button-active { - position: absolute; - width: 25%; - left: 75%; - height: 35px; - background-color: var(--accent); - color: white; - border-top-right-radius: 5px; - border-bottom-right-radius: 5px; -} -.metric-chart-button { - width: 50%; - position: absolute; - left: 50%; - height: 30px; - font-weight: 700; - overflow: hidden; - border-top-right-radius: 5px; - border-bottom-right-radius: 5px; - color: var(--accent); -} -.metric-chart-button-active { - width: 50%; - position: absolute; - left: 50%; - height: 30px; - background-color: var(--accent); - color: white; - font-weight: 700; - overflow: hidden; - border-top-right-radius: 5px; - border-bottom-right-radius: 5px; -} -.metric-summary-button { - width: 50%; - position: absolute; - border-right-color: #ccc; - border-right-style: solid; - border-right-width: 1px; - height: 30px; - font-weight: 700; - overflow: hidden; - border-top-left-radius: 5px; - border-bottom-left-radius: 5px; -} -.metric-summary-button-active { - width: 50%; - position: absolute; - border-right-color: #ccc; - border-right-style: solid; - border-right-width: 1px; - height: 30px; - background-color: var(--accent); - color: white; - font-weight: 700; - overflow: hidden; - border-top-left-radius: 5px; - border-bottom-left-radius: 5px; -} -.distance-button{ - width:25%; - font-size: 12px; - font-weight: 700; - overflow: hidden; - border-top-left-radius: 5px; - border-bottom-left-radius: 5px; - float: left; - height: 30px; -} -.speed-button{ - width:25%; - font-size: 12px; - font-weight: 700; - overflow: hidden; - border-top-right-radius: 5px; - border-bottom-right-radius: 5px; - float: left; - height: 30px; -} -.trips-button{ - width:25%; - font-size: 12px; - font-weight: 700; - overflow: hidden; - float: left; - height: 30px; -} -.duration-button{ - width:25%; - font-size: 12px; - font-weight: 700; - overflow: hidden; - float: left; - height: 30px; -} -.distance-button-active{ - width:25%; - font-size: 12px; - font-weight: 700; - background-color: var(--accent); - color: white; - overflow: hidden; - border-top-left-radius: 5px; - border-bottom-left-radius: 5px; - float: left; - height: 30px; -} -.speed-button-active{ - width:25%; - font-size: 12px; - font-weight: 700; - overflow: hidden; - background-color: var(--accent); - color: white; - border-top-right-radius: 5px; - border-bottom-right-radius: 5px; - float: left; - height: 30px; -} -.trips-button-active{ - width:25%; - font-size: 12px; - font-weight: 700; - background-color: var(--accent); - color: white; - overflow: hidden; - float: left; - height: 30px; -} -.duration-button-active{ - width:25%; - font-size: 12px; - font-weight: 700; - background-color: var(--accent); - color: white; - overflow: hidden; - float: left; - height: 30px; -} -.metric-toggle-button { - width: 33%; - position: absolute; - left: 66%; - height: 33px; -} -.metric-me-toggle { - -} -.metric-icon { - color: #ccc; - font-size: 30px; -} -.metric-filter-title { - position: absolute; - width: 15%; - height: 35px; - 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 */ - padding-left: 10px; -} -.metric-filter-year { - position: absolute; - width: 19%; - 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; -} -.metric-filter-month { - position: absolute; - width: 13%; - 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; -} -.metric-filter-day { - position: absolute; - width: 13%; - 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; -} -.metric-summary-title { - padding: 2px; - color: var(--accent); - font-weight: 700; -} -.metric-summary-content { - color: var(--accent); - font-size: 12px; - font-weight: 600; -} -.metric-summary-left { - float: left; - margin-top: 10px; - width: 40px !important; - margin-left: 10px; - -} -.metric-summary-right { - margin-left: 40px; -} -.metric-summary-separator { - border-bottom-color: #ccc; - border-bottom-style: solid; - border-bottom-width: 1px; - color: #ccc; - margin-bottom: 10px; -} - -.metric-summary-option-button { - float: right; - color: var(--accent); - background-color: white; - height: 20px; - border-radius: 5px; - 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); -} -.full-toggle-container { - height: 35px; - - -} -.full-toggle-left { - width: 50%; - float: left; - height: 35px; - - border-top-left-radius: 5px; - border-bottom-left-radius: 5px; - - background-color: white; - color: var(--accent); - border-right-style: solid; - border-right-width: 1px; - border-right-color: #ccc; -} -.full-toggle-left-active { - width: 50%; - float: left; - height: 35px; - - border-top-left-radius: 5px; - border-bottom-left-radius: 5px; - - background-color: var(--accent); - color: white; - border-right-style: solid; - border-right-width: 1px; - border-right-color: #ccc; -} -.full-toggle-right { - width: 50%; - float: right; - height: 35px; - - border-top-right-radius: 5px; - border-bottom-right-radius: 5px; - - background-color: white; - color: var(--accent); -} -.full-toggle-right-active { - width: 50%; - float: right; - height: 35px; - - border-top-right-radius: 5px; - border-bottom-right-radius: 5px; - - background-color: var(--accent); - color: white; -} -.unit-toggle-container { - height: 35px; -} -.unit-toggle-left { - width: 50%; - float: left; - height: 35px; - border-top-left-radius: 5px; - border-bottom-left-radius: 5px; - background-color: white; - color: var(--accent); - border-right-style: solid; - border-right-width: 1px; - border-right-color: #ccc; -} -.unit-toggle-left-active { - width: 50%; - float: left; - height: 35px; - border-top-left-radius: 5px; - border-bottom-left-radius: 5px; - background-color: var(--accent); - color: white; - border-right-style: solid; - border-right-width: 1px; - border-right-color: #ccc; -} -.unit-toggle-right { - width: 50%; - float: right; - height: 35px; - border-top-right-radius: 5px; - border-bottom-right-radius: 5px; - background-color: white; - color: var(--accent); -} -.unit-toggle-right-active { - width: 50%; - float: right; - height: 35px; - border-top-right-radius: 5px; - border-bottom-right-radius: 5px; - background-color: var(--accent); - color: white; -} -.userdata-title { - color: #ccc; -} -.carbon-goal-chart { - height: 5px; - background-color: var(--accent); - opacity: 0.6; - border-radius: 2px; - width: 100%; -} -#no-border.item { - border-width: 0 !important; -} -#goal-signup-field{ - width: 50%; - margin-left: auto; - margin-right: auto; - margin-bottom: 10px; -} -.item.item-input.item-select { - border-width: 1px !important; -} -.control-icon-button:active { - opacity: 0.7; -} -.full-toggle-left:active { - opacity: 0.7; -} -.full-toggle-right:active { - opacity: 0.7; -} -#iframe { - /*width: 375px !important;*/ - height: 100%; - -webkit-overflow-scrolling: touch !important; - overflow: scroll !important; -} - -.buttons { - margin-left: 10px; -} - -.filter-select-wrapper, -.date-input-wrapper { - display: flex; - margin: 0 2px; - position: relative; - pointer-events: none; -} - -.buttons input, -.buttons select { - text-align: center; - border: 1px solid rgb(20 20 20 / .2); - border-radius: 10px; - font-size: 13px; - color: #222; - pointer-events: all; - min-width: 11ch; -} - -.buttons input[type="date"] { - color: transparent; -} - -.date-input-wrapper:before { - content: attr(data-text) !important; - position: absolute; - top: 50%; - translate: 0 -50%; - left: 8px; - font-size: 12px; - line-height: 1.3; -} - -.date-input-wrapper:first-line { - text-decoration: underline; -} - - -.date-input-wrapper:after { - content: "ļ„—"; - position: absolute; - top: 50%; - translate: 0 -50%; - right: 8px; - font-size: 14px; - font-family: "Ionicons"; -} - -.date-input-divider { - position: absolute; - color: black; - border-top: 1px solid #444; - top: 50%; - margin: 0; - width: 3ch; - left: calc(8px + 2.5ch); -} diff --git a/www/css/style.scss b/www/css/style.scss new file mode 100644 index 000000000..e2172a632 --- /dev/null +++ b/www/css/style.scss @@ -0,0 +1,33 @@ +/* This file is in Sassy CSS (SCSS) because Enketo uses SCSS and we want to extend on their styles. + Webpack will just compile this to plain CSS anyway */ + +@import 'leaflet/dist/leaflet.css'; + +html { + font-family: 'OpenSans', Arial, sans-serif; +} + +/* Scoped styles for Enketo */ +/* if we don't contain them here, they will leak into the rest of the app */ +.enketo-plugin { + // Enketo's default theme uses orange; we can override with our own blue + $primary: #0080b9; // matches 'primary' in appTheme.ts + $brand-primary-color: darken($primary, 5%); // make it a bit darker for legibility in Enketo UI + @import 'enketo-core/src/sass/formhub/formhub.scss'; + flex: 1; + .question.non-select { + display: inline-block; + } + .question input[name$='Start_date'], + .question input[name$='Start_time'], + .question input[name$='End_date'], + .question input[name$='End_time'] { + width: calc(40vw - 10px); + margin-right: 5px; + display: flex; + } +} + +.enketo-plugin .form-header { + max-height: 50px; +} diff --git a/www/i18n/en.json b/www/i18n/en.json index e47fdd62d..9a8b6bb61 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -1,427 +1,498 @@ { - "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", + "bluetooth-scan": "Scan for Bluetooth", + "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", + "refresh-app-config": "Refresh App Configuration", + "current-version": "Current version: {{version}}", + "refreshing-app-config": "Refreshing app configuration, please wait...", + "already-up-to-date": "Already up to date!" + }, - "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-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-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" + }, - "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ā‚‚)" - }, + "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 (scroll for more)", + "select-replaced-mode-scroll": "Replaces (scroll for more)", + "select-purpose-scroll": "Purpose (scroll 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!" + }, - "details":{ - "speed": "Speed", - "time": "Time" - }, + "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" + }, - "list-datepicker-today": "Today", - "list-datepicker-close": "Close", - "list-datepicker-set": "Set", + "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ā‚‚)" + }, - "service":{ - "reading-server": "Reading from server...", - "reading-unprocessed-data": "Reading unprocessed data..." - }, + "details": { + "speed": "Speed", + "time": "Time" + }, - "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" - } + "list-datepicker-today": "Today", + "list-datepicker-close": "Close", + "list-datepicker-set": "Set", + + "bluetooth": { + "title": { + "ble": "BLE Beacon Scanner", + "classic": "Bluetooth Classic Scanner" + }, + "scan": { + "for-ble": "Scan for BLE Beacons", + "for-bluetooth": "Scan for Classic Devices", + "stop": "Stop Scanning" }, + "is-scanning": "Scanning...", + "device-info": { + "id": "ID", + "name": "Name" + }, + "switch-to": { + "classic": "Switch to Classic", + "ble": "Switch to BLE" + } + }, + + "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", + "more-info": "More Info", + "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-initializing-label": "While initializing Label tab: ", + "while-loading-pipeline-range": "Error while loading pipeline range", + "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-updating-timeline": "While updating timeline: ", + "while-refreshing-label": "While refreshing Label tab: ", + "while-repopulating-entry": "While repopulating timeline entry: ", + "while-loading-metrics": "While loading metrics: ", + "while-log-messages": "While getting messages from the log ", + "while-max-index": "While getting max index ", + "while-scanning-bluetooth": "While scanning for Bluetooth Devices: " + }, + "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/img/adam.jpg b/www/img/adam.jpg deleted file mode 100644 index 5a5d37ccc..000000000 Binary files a/www/img/adam.jpg and /dev/null differ diff --git a/www/img/avatar_1.png b/www/img/avatar_1.png deleted file mode 100644 index d4ff83f76..000000000 Binary files a/www/img/avatar_1.png and /dev/null differ diff --git a/www/img/banana.png b/www/img/banana.png deleted file mode 100644 index 0a7beedf6..000000000 Binary files a/www/img/banana.png and /dev/null differ diff --git a/www/img/ben.png b/www/img/ben.png deleted file mode 100644 index 374d32ef1..000000000 Binary files a/www/img/ben.png and /dev/null differ diff --git a/www/img/cookie.png b/www/img/cookie.png deleted file mode 100644 index ec7f5a5c7..000000000 Binary files a/www/img/cookie.png and /dev/null differ diff --git a/www/img/ic_header_gem.png b/www/img/ic_header_gem.png deleted file mode 100644 index 30f8b4dea..000000000 Binary files a/www/img/ic_header_gem.png and /dev/null differ diff --git a/www/img/ic_header_gold.png b/www/img/ic_header_gold.png deleted file mode 100644 index c9fd24d1b..000000000 Binary files a/www/img/ic_header_gold.png and /dev/null differ diff --git a/www/img/ic_header_healer.png b/www/img/ic_header_healer.png deleted file mode 100644 index c52dc41eb..000000000 Binary files a/www/img/ic_header_healer.png and /dev/null differ diff --git a/www/img/ic_header_mage.png b/www/img/ic_header_mage.png deleted file mode 100644 index 3395fd5fc..000000000 Binary files a/www/img/ic_header_mage.png and /dev/null differ diff --git a/www/img/ic_header_rogue.png b/www/img/ic_header_rogue.png deleted file mode 100644 index 7ac2929c7..000000000 Binary files a/www/img/ic_header_rogue.png and /dev/null differ diff --git a/www/img/ic_header_silver.png b/www/img/ic_header_silver.png deleted file mode 100644 index a07fc2581..000000000 Binary files a/www/img/ic_header_silver.png and /dev/null differ diff --git a/www/img/ic_header_warrior.png b/www/img/ic_header_warrior.png deleted file mode 100644 index 0b76436de..000000000 Binary files a/www/img/ic_header_warrior.png and /dev/null differ diff --git a/www/img/ic_navigation_black_24dp.png b/www/img/ic_navigation_black_24dp.png deleted file mode 100644 index 760fcd193..000000000 Binary files a/www/img/ic_navigation_black_24dp.png and /dev/null differ diff --git a/www/img/icecream.png b/www/img/icecream.png deleted file mode 100644 index 5a4f46436..000000000 Binary files a/www/img/icecream.png and /dev/null differ diff --git a/www/img/intro/splash_screen_logo.png b/www/img/intro/splash_screen_logo.png deleted file mode 100644 index ffabdd377..000000000 Binary files a/www/img/intro/splash_screen_logo.png and /dev/null differ diff --git a/www/img/ionic.png b/www/img/ionic.png deleted file mode 100644 index 21c7f3759..000000000 Binary files a/www/img/ionic.png and /dev/null differ diff --git a/www/img/max.png b/www/img/max.png deleted file mode 100644 index a4ab62b2f..000000000 Binary files a/www/img/max.png and /dev/null differ diff --git a/www/img/mike.png b/www/img/mike.png deleted file mode 100644 index e9abd4ddd..000000000 Binary files a/www/img/mike.png and /dev/null differ diff --git a/www/img/minus.gif b/www/img/minus.gif deleted file mode 100644 index 0115810b9..000000000 Binary files a/www/img/minus.gif and /dev/null differ diff --git a/www/img/nileredsea_126b5740_small.png b/www/img/nileredsea_126b5740_small.png deleted file mode 100644 index a1e11ca24..000000000 Binary files a/www/img/nileredsea_126b5740_small.png and /dev/null differ diff --git a/www/img/pacman.gif b/www/img/pacman.gif deleted file mode 100644 index 8201c5d6e..000000000 Binary files a/www/img/pacman.gif and /dev/null differ diff --git a/www/img/perry.png b/www/img/perry.png deleted file mode 100644 index 2ab43ffbd..000000000 Binary files a/www/img/perry.png and /dev/null differ diff --git a/www/img/plus.gif b/www/img/plus.gif deleted file mode 100644 index 6879c8743..000000000 Binary files a/www/img/plus.gif and /dev/null differ diff --git a/www/index.html b/www/index.html index 451c3047f..696343b7a 100644 --- a/www/index.html +++ b/www/index.html @@ -1,18 +1,15 @@ - + - + - - - - - -
+ + +
diff --git a/www/index.js b/www/index.js index 55cb233b5..cd4757f3f 100644 --- a/www/index.js +++ b/www/index.js @@ -1,34 +1,50 @@ -import './manual_lib/ionic/css/ionic.css'; -import './css/style.css'; -import './css/main.diary.css'; -import 'leaflet/dist/leaflet.css'; - -import './js/ngApp.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'; -import './js/splash/localnotify.js'; -import './js/splash/remotenotify.js'; -import './js/splash/notifScheduler.js'; -import './js/controllers.js'; -import './js/services.js'; -import './js/i18n-utils.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/diary/services.js'; -import './js/survey/enketo/answer.js'; -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/uploadService.js'; -import './js/metrics-factory.js'; -import './js/metrics-mappings.js'; -import './js/plugin/logger.ts'; -import './js/plugin/storage.js'; +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { Provider as PaperProvider } from 'react-native-paper'; + +import './css/style.scss'; +import 'chartjs-adapter-luxon'; + +import initializedI18next from './js/i18nextInit'; +window.i18next = initializedI18next; + +import App from './js/App'; +import { getTheme } from './js/appTheme'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { logDebug } from './js/plugin/logger'; + +export const deviceReady = new Promise((resolve) => { + document.addEventListener('deviceready', resolve); +}); + +/* ensure that plugin events are not delivered before Cordova is ready: + https://github.com/katzer/cordova-plugin-local-notifications#launch-details */ +window.skipLocalNotificationReady = true; + +deviceReady.then(() => { + logDebug('deviceReady'); + /* give status bar dark text because we have a light background + https://cordova.apache.org/docs/en/10.x/reference/cordova-plugin-statusbar/#statusbarstyledefault */ + if (window['StatusBar']) window['StatusBar'].styleDefault(); + cordova.plugin.http.setDataSerializer('json'); + const rootEl = document.getElementById('appRoot'); + const reactRoot = createRoot(rootEl); + + const theme = getTheme(); + + reactRoot.render( + + + + + + , + ); +}); diff --git a/www/js/App.tsx b/www/js/App.tsx index 3c6c8bec9..648b93d86 100644 --- a/www/js/App.tsx +++ b/www/js/App.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useState, createContext, useMemo } from 'react'; -import { getAngularService } from './angular-react-helper'; import { ActivityIndicator, BottomNavigation, useTheme } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; import LabelTab from './diary/LabelTab'; @@ -7,66 +6,100 @@ 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'; +import { initPushNotify } from './splash/pushNotifySettings'; +import { initStoreDeviceSettings } from './splash/storeDeviceSettings'; +import { initRemoteNotifyHandler } from './splash/remoteNotifyHandler'; +import { withErrorBoundary } from './plugin/ErrorBoundary'; +import { initCustomDatasetHelper } from './metrics/customMetricsHelper'; +import AlertBar from './components/AlertBar'; 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', + accessibilityLabel: t('diary.label-tab'), + }, + { + key: 'metrics', + title: t('metrics.dashboard-tab'), + focusedIcon: 'chart-box', + unfocusedIcon: 'chart-box-outline', + accessibilityLabel: t('metrics.dashboard-tab'), + }, + { + key: 'control', + title: t('control.profile-tab'), + focusedIcon: 'account', + unfocusedIcon: 'account-outline', + accessibilityLabel: t('control.profile-tab'), + }, ]; export const AppContext = createContext({}); -const App = () => { +const scenes = { + label: withErrorBoundary(LabelTab), + metrics: withErrorBoundary(MetricsTab), + control: withErrorBoundary(ProfileSettings), +}; +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(); 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'); + return showMetrics ? defaultRoutes(t) : defaultRoutes(t).filter((r) => r.key != 'metrics'); }, [appConfig, t]); - const renderScene = BottomNavigation.SceneMap({ - label: LabelTab, - metrics: MetricsTab, - control: ProfileSettings, - }); + const renderScene = BottomNavigation.SceneMap(scenes); const refreshOnboardingState = () => getPendingOnboardingState().then(setOnboardingState); - useEffect(() => { refreshOnboardingState() }, []); + useEffect(() => { + refreshOnboardingState(); + }, []); useEffect(() => { if (!appConfig) return; setServerConnSettings(appConfig).then(() => { refreshOnboardingState(); }); + initPushNotify(); + initStoreDeviceSettings(); + initRemoteNotifyHandler(); + initCustomDatasetHelper(appConfig); }, [appConfig]); const appContextValue = { appConfig, - onboardingState, setOnboardingState, refreshOnboardingState, + onboardingState, + setOnboardingState, + refreshOnboardingState, permissionStatus, - permissionsPopupVis, setPermissionsPopupVis, - } - - console.debug('onboardingState in App', onboardingState); + permissionsPopupVis, + setPermissionsPopupVis, + }; 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 = ( @@ -80,24 +113,28 @@ 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 deleted file mode 100644 index 984e529ff..000000000 --- a/www/js/angular-react-helper.tsx +++ /dev/null @@ -1,80 +0,0 @@ -// ---- angular-react-helper.jsx ---- -// Adapted from https://dev.to/kaplona/angularjs-to-react-migration-184g -// Modified to use React 18 and wrap elements with the React Native Paper Provider - -import angular from 'angular'; -import { createRoot } from 'react-dom/client'; -import React from 'react'; -import { Provider as PaperProvider, MD3LightTheme as DefaultTheme, MD3Colors } from 'react-native-paper'; -import { getTheme } from './appTheme'; - -function toBindings(propTypes) { - const bindings = {}; - Object.keys(propTypes).forEach(key => bindings[key] = '<'); - return bindings; -} - -function toProps(propTypes, controller) { - const props = {}; - Object.keys(propTypes).forEach(key => props[key] = controller[key]); - return props; -} - -export function angularize(component, name, modulePath) { - component.module = modulePath; - const nameCamelCase = name[0].toLowerCase() + name.slice(1); - angular - .module(modulePath, []) - .component(nameCamelCase, makeComponentProps(component)); -} - -const theme = getTheme(); -export function makeComponentProps(Component) { - const propTypes = Component.propTypes || {}; - return { - bindings: toBindings(propTypes), - controller: ['$element', function($element) { - /* TODO: once the inf scroll list is converted to React and no longer uses - collection-repeat, we can just set the root here one time - and will not have to reassign it in $onChanges. */ - /* Until then, React will complain everytime we reassign an element's root */ - let root; - this.$onChanges = () => { - root = createRoot($element[0]); - const props = toProps(propTypes, this); - root.render( - - - - - ); - }; - this.$onDestroy = () => root.unmount(); - }] - }; -} - -export function getAngularService(name: string) { - const injector = angular.element(document.body).injector(); - if (!injector || !injector.get) { - throw new Error(`Couldn't find angular injector to get "${name}" service`); - } - - const service = injector.get(name); - if (!service) { - throw new Error(`Couldn't find "${name}" angular service`); - } - - return (service as any); // casting to 'any' because not all Angular services are typed -} - -export function createScopeWithVars(vars) { - 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..b66f493e6 100644 --- a/www/js/appTheme.ts +++ b/www/js/appTheme.ts @@ -1,4 +1,4 @@ -import { MD3LightTheme as DefaultTheme, MD3Theme } from 'react-native-paper'; +import { MD3LightTheme as DefaultTheme, MD3Theme, useTheme } from 'react-native-paper'; /* This is the base theme we will use throughout the app It's based on the default theme from React Native Paper, with some modifications */ @@ -18,6 +18,10 @@ const AppTheme = { surfaceVariant: '#e0f0ff', // lch(94% 50 250) - background of DataTable surfaceDisabled: '#c7e0f7', // lch(88% 15 250) onSurfaceDisabled: '#3a4955', // lch(30% 10 250) + // "inverse" colors - used for SnackBars / AlertBars + inversePrimary: '#90ceff', // lch(80% 35 250) - SnackBar colored text + inverseSurface: '#2e3133', // lch(20% 2 250) - SnackBar background + inverseOnSurface: '#edf1f6', // lch(95% 3 250) - SnackBar text elevation: { level0: 'transparent', level1: '#fafdff', // lch(99% 30 250) @@ -28,11 +32,13 @@ 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, }; +export const useAppTheme = () => useTheme(); + /* Next, we'll set up 'flavors' of the theme, which are variations on the theme with any number of properties overridden. This is used to automatically style the different types of diary cards @@ -47,23 +53,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,16 +83,19 @@ const flavorOverrides = { level1: '#e1e3e4', // lch(90 1 250) level2: '#d2d5d8', // lch(85 2 250) }, - } + }, }, } satisfies Record; /* This function is used to retrieve the theme for a given flavor. If no valid flavor is specified, it returns the default theme. */ -export const getTheme = (flavor?: keyof typeof flavorOverrides) => { - if (!flavorOverrides[flavor]) return AppTheme; +export function getTheme(flavor?: keyof typeof flavorOverrides) { + if (!flavor || !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..0eb9fdd60 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, useEffect, 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 { AlertManager } from '../components/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, explanationList } = permissionStatus; - return ( - <> - {t('consent.permissions')} - - {t('intro.appstatus.overall-description')} - - - {checkList?.map((lc) => - - - )} - - - - - + useEffect(() => { + if (!error) return; + AlertManager.addMessage({ + text: error, + }); + }, [error]); - - - ) -} + 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/bluetooth/BluetoothCard.tsx b/www/js/bluetooth/BluetoothCard.tsx new file mode 100644 index 000000000..a244be7cf --- /dev/null +++ b/www/js/bluetooth/BluetoothCard.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { Card, List, Text, Button, useTheme } from 'react-native-paper'; +import { StyleSheet } from 'react-native'; + +type Props = any; +const BluetoothCard = ({ device, isClassic, isScanningBLE }: Props) => { + const { colors } = useTheme(); + if (isClassic) { + return ( + + } + /> + + ); + } + + let bgColor = colors.onPrimary; // 'rgba(225,225,225,1)' + if (isScanningBLE) { + bgColor = device.in_range ? `rgba(200,250,200,1)` : `rgba(250,200,200,1)`; + } + + async function fakeMonitorCallback(state: String) { + // If we don't do this, the results start accumulating in the device object + // first call, we put a result into the device + // second call, the device already has a result, so we put another one in... + const deviceWithoutResult = { ...device }; + deviceWithoutResult.monitorResult = undefined; + deviceWithoutResult.rangeResult = undefined; + window['cordova'].plugins.locationManager.getDelegate().didDetermineStateForRegion({ + region: deviceWithoutResult, + eventType: 'didDetermineStateForRegion', + state: state, + }); + } + + async function fakeRangeCallback() { + const deviceWithBeacons = { ...device }; + deviceWithBeacons.monitorResult = undefined; + deviceWithBeacons.rangeResult = undefined; + const beacons = [ + { + uuid: device.uuid, + major: device.major | 4567, + minor: device.minor | 1945, + proximity: 'ProximityNear', + accuracy: Math.random() * 1.33, + rssi: Math.random() * -62, + }, + ]; + deviceWithBeacons.minor = device.minor | 4567; + deviceWithBeacons.minor = device.minor | 4567; + window['cordova'].plugins.locationManager.getDelegate().didRangeBeaconsInRegion({ + region: deviceWithBeacons, + beacons: beacons, + eventType: 'didRangeBeaconsInRegion', + state: 'CLRegionStateInside', + }); + } + + return ( + + } + /> + + + {device.monitorResult} + + + {device.rangeResult} + + + Simulate by sending UI transitions + + + + + + + + + ); +}; + +export const cardStyles = StyleSheet.create({ + card: { + position: 'relative', + alignSelf: 'center', + marginVertical: 10, + }, + cardContent: { + flex: 1, + width: '100%', + }, +}); + +export default BluetoothCard; diff --git a/www/js/bluetooth/BluetoothScanPage.tsx b/www/js/bluetooth/BluetoothScanPage.tsx new file mode 100644 index 000000000..b50bf5283 --- /dev/null +++ b/www/js/bluetooth/BluetoothScanPage.tsx @@ -0,0 +1,432 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DateTime } from 'luxon'; +import { StyleSheet, Modal, ScrollView, SafeAreaView, View, Text } from 'react-native'; +import { gatherBluetoothClassicData } from './bluetoothScanner'; +import { logWarn, displayError, displayErrorMsg, logDebug } from '../plugin/logger'; +import BluetoothCard from './BluetoothCard'; +import { Appbar, useTheme, TextInput, Button } from 'react-native-paper'; +import { + BLEBeaconDevice, + BLEPluginCallback, + BluetoothClassicDevice, + BLEDeviceList, +} from '../types/bluetoothDevices'; +import { forceTransition } from '../control/ControlCollectionHelper'; + +/** + * The implementation of this scanner page follows the design of + * `www/js/survey/enketo/EnketoModal.tsx`! + * + * Future work may include refractoring these files to be implementations of a + * single base "pop-up page" component + */ + +const BluetoothScanPage = ({ ...props }: any) => { + const STATIC_ID = 'edu.berkeley.eecs.emission'; + + const { t } = useTranslation(); + const [bluetoothClassicList, setBluetoothClassicList] = useState([]); + const [sampleBLEDevices, setSampleBLEDevices] = useState({ + '426C7565-4368-6172-6D42-6561636F6E74': { + identifier: STATIC_ID, + minor: 4949, + major: 3838, + in_range: false, + }, + '426C7565-4368-6172-6D42-6561636F6E73': { + identifier: STATIC_ID, + minor: 4949, + major: 3838, + in_range: false, + }, + }); + const [isScanningClassic, setIsScanningClassic] = useState(false); + const [isScanningBLE, setIsScanningBLE] = useState(false); + const [isClassic, setIsClassic] = useState(false); + const [newUUID, setNewUUID] = useState(null); + const [newMajor, setNewMajor] = useState(undefined); + const [newMinor, setNewMinor] = useState(undefined); + const { colors } = useTheme(); + + // Flattens the `sampleBeacons` into an array of BLEBeaconDevices + function beaconsToArray() { + return Object.entries(sampleBLEDevices).map(([uuid, device]) => ({ + uuid, + ...device, + })); + } + + // Function to run Bluetooth Classic test and update logs + async function runBluetoothClassicTest() { + // Classic not currently supported on iOS + if (window['cordova'].platformId == 'ios') { + displayErrorMsg('Sorry, iOS is not supported!', 'OSError'); + return; + } + + try { + let response = await window['cordova'].plugins.BEMDataCollection.bluetoothScanPermissions(); + if (response != 'OK') { + displayErrorMsg('Please Enable Bluetooth!', 'Insufficient Permissions'); + return; + } + } catch (e) { + displayError(e, 'Insufficient Permissions'); + return; + } + + try { + setIsScanningClassic(true); + const newLogs = await gatherBluetoothClassicData(t); + setBluetoothClassicList(newLogs); + } catch (error) { + logWarn(error); + } finally { + setIsScanningClassic(false); + } + } + + function setMonitorStatus(uuid: string, result: string, status: boolean) { + setSampleBLEDevices((prevDevices) => ({ + ...prevDevices, + [uuid]: { + ...prevDevices[uuid], + monitorResult: status ? result : undefined, + rangeResult: undefined, + in_range: status, + }, + })); + window['cordova']?.plugins?.BEMDataCollection.mockBLEObjects( + status ? 'REGION_ENTER' : 'REGION_EXIT', + uuid, + undefined, + undefined, + 1, + ); + if (!status) { + forceTransition('BLE_BEACON_LOST'); + } + } + + function setRangeStatus(uuid: string, result: string) { + setSampleBLEDevices((prevDevices) => ({ + ...prevDevices, + [uuid]: { + ...prevDevices[uuid], + rangeResult: result, + }, + })); + let parsedResult = JSON.parse(result); + parsedResult.beacons.forEach((beacon) => { + window['cordova']?.plugins?.BEMDataCollection.mockBLEObjects( + 'RANGE_UPDATE', + uuid, + beacon.major, + beacon.minor, + 5, + ); + }); + // we only check for the transition on "real" callbacks to avoid excessive + // spurious callbacks on android + if (parsedResult.beacons.length > 0) { + // if we have received 3 range responses for the same beacon in the + // last 5 minutes, we generate the transition. we read without metadata + // (last param) + let nowSec = DateTime.now().toUnixInteger(); + let tq = { key: 'write_ts', startTs: nowSec - 5 * 60, endTs: nowSec }; + let readBLEReadingsPromise = window[ + 'cordova' + ]?.plugins?.BEMUserCache.getSensorDataForInterval('background/bluetooth_ble', tq, false); + readBLEReadingsPromise.then((bleResponses) => { + // we add 5 entries at a time, so if we want 3 button presses, + // we really want 15 entries + let lastFifteenResponses = bleResponses.slice(0, 15); + if (!lastFifteenResponses.every((x) => x.eventType == 'RANGE_UPDATE')) { + console.log( + 'Last three entries ' + + lastFifteenResponses.map((x) => x.eventType) + + ' are not all RANGE_UPDATE, skipping transition', + ); + return; + } + + forceTransition('BLE_BEACON_FOUND'); + }); + } + } + + async function simulateLocation(state: String) { + forceTransition(state); + } + + // BLE LOGIC + async function startBeaconScanning() { + setIsScanningBLE(true); + + let delegate = new window['cordova'].plugins.locationManager.Delegate(); + + delegate.didDetermineStateForRegion = function (pluginResult: BLEPluginCallback) { + // `stateInside`is returned when the user enters the beacon region + // `StateOutside` is either (i) left region, or (ii) started scanner (outside region) + const pluginResultStr = JSON.stringify(pluginResult, null, 2); + if (pluginResult.state == 'CLRegionStateInside') { + // need toUpperCase(), b/c callback returns with only lowercase values... + setMonitorStatus(pluginResult.region.uuid.toUpperCase(), pluginResultStr, true); + } else if (pluginResult.state == 'CLRegionStateOutside') { + setMonitorStatus(pluginResult.region.uuid.toUpperCase(), pluginResultStr, false); + } + logDebug('[BLE] didDetermineStateForRegion'); + logDebug(pluginResultStr); + window['cordova'].plugins.locationManager.appendToDeviceLog( + '[DOM] didDetermineStateForRegion: ' + pluginResultStr, + ); + if (pluginResult.state == 'CLRegionStateInside') { + const beaconRegion = new window['cordova'].plugins.locationManager.BeaconRegion( + STATIC_ID, + pluginResult.region.uuid, + pluginResult.region.major, + pluginResult.region.minor, + ); + console.log('About to start ranging beacons for region ', beaconRegion); + window['cordova'].plugins.locationManager + .startRangingBeaconsInRegion(beaconRegion) + .fail(function (e) { + logWarn(e); + }) + .done(); + } + }; + + delegate.didStartMonitoringForRegion = function (pluginResult) { + logDebug('[BLE] didStartMonitoringForRegion'); + logDebug(JSON.stringify(pluginResult)); + }; + + delegate.didRangeBeaconsInRegion = function (pluginResult) { + // Not seeing this called... + logDebug('[BLE] didRangeBeaconsInRegion'); + const pluginResultStr = JSON.stringify(pluginResult, null, 2); + logDebug(pluginResultStr); + setRangeStatus(pluginResult.region.uuid.toUpperCase(), pluginResultStr); + }; + + window['cordova'].plugins.locationManager.setDelegate(delegate); + + // Setup regions for each beacon + beaconsToArray().forEach((sampleBeacon: BLEBeaconDevice) => { + // Use NULL for wildcard + // Need UUID value on iOS only, not Android (2nd parameter) + // https://stackoverflow.com/questions/38580410/how-to-scan-all-nearby-ibeacons-using-coordova-based-hybrid-application + const beaconRegion = new window['cordova'].plugins.locationManager.BeaconRegion( + STATIC_ID, + sampleBeacon.uuid, + sampleBeacon.major, + sampleBeacon.minor, + ); + window['cordova'].plugins.locationManager + .startMonitoringForRegion(beaconRegion) + .fail(function (e) { + logWarn(e); + }) + .done(); + }); + } + + async function stopBeaconScanning() { + setIsScanningBLE(false); + + beaconsToArray().forEach((sampleBeacon: BLEBeaconDevice) => { + setMonitorStatus(sampleBeacon.uuid, false); // "zero out" the beacons + const beaconRegion = new window['cordova'].plugins.locationManager.BeaconRegion( + STATIC_ID, + sampleBeacon.uuid, + sampleBeacon.major, + sampleBeacon.minor, + ); + window['cordova'].plugins.locationManager + .stopMonitoringForRegion(beaconRegion) + .fail(function (e) { + logWarn(e); + }) + .done(); + }); + } + + const switchMode = () => { + setIsClassic(!isClassic); + }; + + // Add a beacon with the new UUID to the list of BLE devices to scan + function addNewUUID(newUUID: string, newMajor: number, newMinor: number) { + console.log('Before adding UUID ' + newUUID + ' entries = ' + sampleBLEDevices); + const devicesWithAddition = { ...sampleBLEDevices }; + devicesWithAddition[newUUID] = { + identifier: STATIC_ID, + minor: newMajor, + major: newMinor, + in_range: false, + }; + setSampleBLEDevices(devicesWithAddition); + setNewUUID(null); + setNewMajor(undefined); + setNewMinor(undefined); + } + + const BluetoothCardList = ({ devices }) => { + if (isClassic) { + // When in classic mode, render devices as normal + return ( +
+ {devices.map((device) => { + if (device) { + return ; + } + return null; + })} +
+ ); + } + const beaconsAsArray = beaconsToArray(); + return ( +
+ {beaconsAsArray.map((beacon) => { + if (beacon) { + return ( + + ); + } + })} +
+ ); + }; + + const ScanButton = () => { + if (isClassic) { + return ( + + + + ); + } + // else, if BLE + return ( + + + + ); + }; + + const BlueScanContent = () => ( +
+ + { + props.onDismiss?.(); + }} + /> + + + + + + + +
+ ); + + return ( + <> + + + + + + setNewUUID(t.toUpperCase())} + /> + + setNewMajor(t)} + /> + setNewMinor(t)} + /> + + + + + Simulate by sending UI transitions + + + + + + + + + + + ); +}; + +const s = StyleSheet.create({ + btnContainer: { + padding: 8, + justifyContent: 'center', + }, + btn: { + height: 38, + fontSize: 11, + margin: 4, + }, +}); + +export default BluetoothScanPage; diff --git a/www/js/bluetooth/bluetoothScanner.ts b/www/js/bluetooth/bluetoothScanner.ts new file mode 100644 index 000000000..d7cb2d297 --- /dev/null +++ b/www/js/bluetooth/bluetoothScanner.ts @@ -0,0 +1,54 @@ +import { logDebug, displayError } from '../plugin/logger'; +import { BluetoothClassicDevice } from '../types/bluetoothDevices'; + +/** + * gatherBluetoothData scans for viewable Bluetooth Classic Devices + * @param t is the i18next translation function + * @returns an array of strings containing device data, formatted ['ID: id Name: name'] + */ +export function gatherBluetoothClassicData(t): Promise { + return new Promise((resolve, reject) => { + logDebug('Running bluetooth discovery test!'); + + // Device List "I/O" + function updatePairingStatus(pairingType: boolean, devices: Array) { + devices.forEach((device) => { + device.is_paired = pairingType; + }); + return devices; + } + + // Plugin Calls + const unpairedDevicesPromise = new Promise((res, rej) => { + window['bluetoothClassicSerial'].discoverUnpaired( + (devices: Array) => { + res(updatePairingStatus(false, devices)); + }, + (e: Error) => { + displayError(e, 'Error'); + rej(e); + }, + ); + }); + + const pairedDevicesPromise = new Promise((res, rej) => { + window['bluetoothClassicSerial'].list( + (devices: Array) => { + res(updatePairingStatus(true, devices)); + }, + (e: Error) => { + displayError(e, 'Error'); + rej(e); + }, + ); + }); + + Promise.all([unpairedDevicesPromise, pairedDevicesPromise]) + .then((logs: Array) => { + resolve(logs.flat()); + }) + .catch((e) => { + reject(e); + }); + }); +} diff --git a/www/js/commHelper.ts b/www/js/commHelper.ts deleted file mode 100644 index 074093999..000000000 --- a/www/js/commHelper.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { logDebug } from "./plugin/logger"; - -/** - * @param url URL endpoint for the request - * @returns Promise of the fetched response (as text) or cached text from local storage - */ -export async function fetchUrlCached(url) { - const stored = localStorage.getItem(url); - if (stored) { - logDebug(`fetchUrlCached: found cached data for url ${url}, returning`); - return Promise.resolve(stored); - } - logDebug(`fetchUrlCached: found no cached data for url ${url}, fetching`); - const response = await fetch(url); - const text = await response.text(); - localStorage.setItem(url, text); - logDebug(`fetchUrlCached: fetched data for url ${url}, returning`); - return text; -} 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/AlertBar.tsx b/www/js/components/AlertBar.tsx new file mode 100644 index 000000000..6bdd8d157 --- /dev/null +++ b/www/js/components/AlertBar.tsx @@ -0,0 +1,59 @@ +/* Provides a global context for alerts to show as SnackBars ('toasts') at the bottom of the screen. + Alerts can be added to the queue from anywhere by calling AlertManager.addMessage. */ + +import React, { useState, useEffect } from 'react'; +import { Snackbar } from 'react-native-paper'; +import { Modal } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import { ParseKeys } from 'i18next'; + +type AlertMessage = { + msgKey?: ParseKeys<'translation'>; + text?: string; + duration?: number; +}; + +// public static AlertManager that can add messages from a global context +export class AlertManager { + private static listener?: (msg: AlertMessage) => void; + static setListener(listener?: (msg: AlertMessage) => void) { + AlertManager.listener = listener; + } + static addMessage(msg: AlertMessage) { + AlertManager.listener?.(msg); + } +} + +const AlertBar = () => { + const { t } = useTranslation(); + const [messages, setMessages] = useState([]); + const onDismissSnackBar = () => setMessages(messages.slice(1)); + + // on init, attach a listener to AlertManager so messages can be added from a global context + useEffect(() => { + AlertManager.setListener((msg) => { + setMessages([...messages, msg]); + }); + return () => AlertManager.setListener(undefined); + }, []); + + if (!messages.length) return null; + const { msgKey, text } = messages[0]; + const alertText = [msgKey && t(msgKey), text].filter((x) => x).join(' '); + return ( + + + {alertText} + + + ); +}; + +export default AlertBar; 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..2e5e3bd62 100644 --- a/www/js/components/Chart.tsx +++ b/www/js/components/Chart.tsx @@ -1,56 +1,71 @@ - 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 { ChartData, Chart as ChartJS, ScriptableContext, 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'; +import { logDebug } from '../plugin/logger'; 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 +75,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 +97,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) => { + logDebug(`Horizontal axis callback: i = ${i}; + chartDatasets = ${JSON.stringify(chartDatasets)}; + chartDatasets[0].data = ${JSON.stringify(chartDatasets[0].data)}`); + //account for different data possiblities + const label = + chartDatasets[0].data[i]?.y || chartDatasets[i].data[0]?.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) => { + logDebug(`Vertical axis callback: i = ${i}; + chartDatasets = ${JSON.stringify(chartDatasets)}; + chartDatasets[0].data = ${JSON.stringify(chartDatasets[0].data)}`); + //account for different data possiblities - one mode per week, one mode both weeks, mixed weeks + const label = + chartDatasets[0].data[i]?.x || chartDatasets[i].data[0]?.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 && { + ...(lineAnnotations?.length && { 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 }, + // @ts-ignore + borderRadius: 0, + backgroundColor: 'rgba(0,0,0,.7)', + color: 'rgba(255,255,255,1)', + font: { size: 10 }, + position: a.position || 'start', + ...(a.label && { + 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..fef584398 100644 --- a/www/js/components/DiaryButton.tsx +++ b/www/js/components/DiaryButton.tsx @@ -1,28 +1,24 @@ -import React from "react"; +import React from 'react'; import { StyleSheet } from 'react-native'; -import { Button, ButtonProps, useTheme } from 'react-native-paper'; +import { Button, ButtonProps, Icon, 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) => { +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 ( - @@ -42,16 +38,15 @@ const s = StyleSheet.create({ height: 25, }, label: { + display: 'flex', + alignItems: 'center', marginHorizontal: 5, marginVertical: 0, fontSize: 13, fontWeight: '500', whiteSpace: 'nowrap', + gap: 4, }, - icon: { - marginRight: 4, - verticalAlign: 'middle', - } }); export default DiaryButton; diff --git a/www/js/components/Icon.tsx b/www/js/components/Icon.tsx deleted file mode 100644 index 0b4c7253e..000000000 --- a/www/js/components/Icon.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/* React Native Paper provides an IconButton component, but it doesn't provide a plain Icon. - We want a plain Icon that is 'presentational' - not seen as interactive to the user or screen readers, and - it should not have any extra padding or margins around it. */ -/* Using the underlying Icon from React Native Paper doesn't bundle correctly, so the easiest thing to do - for now is wrap an IconButton and remove its interactivity and padding. */ - -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' - -export const Icon = ({style, ...rest}: IconButtonProps) => { - return ( - - ); -} - -const s = StyleSheet.create({ - icon: { - width: 'unset', - height: 'unset', - padding: 0, - margin: 0, - }, -}); diff --git a/www/js/components/LeafletView.tsx b/www/js/components/LeafletView.tsx index cf26cb933..978028dac 100644 --- a/www/js/components/LeafletView.tsx +++ b/www/js/components/LeafletView.tsx @@ -1,60 +1,104 @@ -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, useMemo, useRef } from 'react'; +import { View, ViewProps } from 'react-native'; +import { useTheme } from 'react-native-paper'; +import L, { Map as LeafletMap } from 'leaflet'; +import { GeoJSONData, GeoJSONStyledFeature } from '../types/diaryTypes'; +import useLeafletCache from './useLeafletCache'; const mapSet = new Set(); + +// open the URL in the system browser & prevent any other effects of the click event +window['launchURL'] = (url, event) => { + window['cordova'].InAppBrowser.open(url, '_system'); + event.stopPropagation(); + return false; +}; +const osmURL = 'http://www.openstreetmap.org/copyright'; +const leafletURL = 'https://leafletjs.com'; + 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); +type Props = ViewProps & { + geojson: GeoJSONData; + opts?: L.MapOptions; + downscaleTiles?: boolean; + cacheHtml?: boolean; +}; +const LeafletView = ({ geojson, opts, downscaleTiles, cacheHtml, ...otherProps }: Props) => { + const mapElRef = useRef(null); + const leafletMapRef = useRef(null); + const geoJsonIdRef = useRef(null); const { colors } = useTheme(); + const leafletCache = useLeafletCache(); + + // unique ID for map element, like "map-5f3e3b" or "map-5f3e3b-downscaled" + const mapElId = useMemo(() => { + let id = 'map-'; + // non-alphanumeric characters are not safe for element IDs + id += geojson.data.id.replace(/[^a-zA-Z0-9]/g, ''); + if (downscaleTiles) id += '-downscaled'; + return id; + }, [geojson.data.id, downscaleTiles]); - function initMap(map) { - L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap', + function initMap(map: LeafletMap) { + map.attributionControl?.setPrefix( + `Leaflet`, + ); + const tileLayer = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: `© OpenStreetMap`, opacity: 1, - detectRetina: true, + detectRetina: !downscaleTiles, }).addTo(map); const gj = L.geoJson(geojson.data, { pointToLayer: pointToLayer, - style: (feature) => feature.style + style: (feature) => (feature as GeoJSONStyledFeature)?.style || {}, }).addTo(map); const gjBounds = gj.getBounds().pad(0.2); map.fitBounds(gjBounds); geoJsonIdRef.current = geojson.data.id; leafletMapRef.current = map; mapSet.add(map); + return tileLayer; } useEffect(() => { + // if a Leaflet map is cached, there is no need to create the map again + if (cacheHtml && leafletCache.has(mapElId)) return; // if a Leaflet map already exists (because we are re-rendering), remove it before creating a new one if (leafletMapRef.current) { leafletMapRef.current.remove(); mapSet.delete(leafletMapRef.current); } + if (!mapElRef.current) return; const map = L.map(mapElRef.current, opts || {}); - initMap(map); - }, [geojson]); + const tileLayer = initMap(map); + + if (cacheHtml) { + new Promise((resolve) => tileLayer.on('load', resolve)).then(() => { + // After a Leaflet map is rendered, cache the map to reduce the cost for creating a map + const mapHTMLElements = document.getElementById(mapElId); + leafletCache.set(mapElId, mapHTMLElements?.innerHTML); + }); + } + }, [geojson, cacheHtml]); /* If the geojson is different between renders, we need to recreate the map (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)); + if ( + !leafletCache.has(mapElId) && + geoJsonIdRef.current && + geoJsonIdRef.current !== geojson.data.id && + leafletMapRef.current + ) { + leafletMapRef.current.eachLayer((layer) => leafletMapRef.current?.removeLayer(layer)); initMap(leafletMapRef.current); } - // non-alphanumeric characters are not safe for element IDs - 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}); +function 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); } -}; +} export default LeafletView; 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/NavBar.tsx b/www/js/components/NavBar.tsx new file mode 100644 index 000000000..291f0b9e9 --- /dev/null +++ b/www/js/components/NavBar.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import color from 'color'; +import { Appbar, Button, ButtonProps, Icon, useTheme } from 'react-native-paper'; + +const NavBar = ({ children }) => { + const { colors } = useTheme(); + return ( + + {children} + + ); +}; + +export default NavBar; + +// NavBarButton, a greyish button with outline, to be used inside a NavBar + +type Props = ButtonProps & { icon?: string; iconSize?: number }; +export const NavBarButton = ({ children, icon, iconSize, ...rest }: Props) => { + const { colors } = useTheme(); + const buttonColor = color(colors.onBackground).alpha(0.07).rgb().string(); + const outlineColor = color(colors.onBackground).alpha(0.2).rgb().string(); + + return ( + <> + + + ); +}; + +const s = StyleSheet.create({ + navBar: (backgroundColor) => ({ + backgroundColor, + height: 56, + paddingHorizontal: 8, + gap: 5, + }), + btn: (borderColor) => ({ + borderColor, + borderRadius: 10, + }), + btnContent: { + height: 44, + flexDirection: 'row', + paddingHorizontal: 2, + }, + btnLabel: { + fontSize: 12.5, + fontWeight: '400', + height: '100%', + marginHorizontal: 'auto', + marginVertical: 'auto', + display: 'flex', + }, + icon: { + margin: 'auto', + width: 'auto', + height: 'auto', + }, + textWrapper: { + lineHeight: '100%', + marginHorizontal: 5, + justifyContent: 'space-evenly', + alignItems: 'center', + }, +}); diff --git a/www/js/components/NavBarButton.tsx b/www/js/components/NavBarButton.tsx deleted file mode 100644 index 7e9cb1217..000000000 --- a/www/js/components/NavBarButton.tsx +++ /dev/null @@ -1,57 +0,0 @@ -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(); - - return (<> - - ); -}; - -export const s = StyleSheet.create({ - btn: { - borderRadius: 10, - marginLeft: 5, - }, - label: { - fontSize: 12.5, - fontWeight: '400', - height: '100%', - marginHorizontal: 'auto', - marginVertical: 'auto', - display: 'flex', - }, - icon: { - margin: 'auto', - width: 'auto', - height: 'auto', - }, - textWrapper: { - lineHeight: '100%', - marginHorizontal: 5, - justifyContent: 'space-evenly', - alignItems: 'center', - }, -}); - -export default NavBarButton; diff --git a/www/js/components/QrCode.tsx b/www/js/components/QrCode.tsx index 74c66863f..c8547eaf8 100644 --- a/www/js/components/QrCode.tsx +++ b/www/js/components/QrCode.tsx @@ -2,45 +2,60 @@ 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'; +import { logDebug, logWarn } from '../plugin/logger'; export function shareQR(message) { /*code adapted from demo of react-qr-code*/ - const svg = document.querySelector(".qr-code"); + const svg = document.querySelector('.qr-code'); + if (!svg) return logWarn('No QR code found to share'); 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') as CanvasRenderingContext2D; 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 = {}; + const 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, + (result) => { + // On Android apps mostly return completed=false even while it's true + // On Android result.app is currently empty. On iOS it's empty when sharing is cancelled (result.completed=false) + logDebug(`socialsharing: share completed? ' + ${result.completed}; + shared to app: ${result.app}`); + }, + (msg) => { + logWarn('socialsharing: 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..671228b36 100644 --- a/www/js/components/ToggleSwitch.tsx +++ b/www/js/components/ToggleSwitch.tsx @@ -1,15 +1,15 @@ 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 => ({ - value: o.value, + ({ icon: o.icon, + accessibilityLabel: o.value, uncheckedColor: colors.onSurfaceDisabled, showSelectedCheck: true, style: { @@ -18,9 +18,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..f536fc04f 100644 --- a/www/js/components/charting.ts +++ b/www/js/components/charting.ts @@ -1,6 +1,7 @@ import color from 'color'; import { getBaseModeByKey } from '../diary/diaryHelper'; import { readableLabelToKey } from '../survey/multilabel/confirmHelper'; +import { logDebug } from '../plugin/logger'; export const defaultPalette = [ '#c95465', // red oklch(60% 0.15 14) @@ -15,15 +16,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]); - })); + const uniqueIndexVals: string[] = []; + 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 +50,9 @@ 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('_')){ + for (let val in stacks.x) { + if (!val.startsWith('_')) { totalHeight += stacks.x[val]; - console.log("ctx added ", val ); } } return totalHeight; @@ -54,27 +61,27 @@ 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') as CanvasRenderingContext2D; + 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); + logDebug(`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,28 +102,42 @@ 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) { +export function getGradient( + chart, + meter, + currDataset, + barCtx, + alpha: number | null = null, + darken = 0, +) { const { ctx, chartArea, scales } = chart; if (!chartArea) return null; let gradient: CanvasGradient; 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 +150,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(); } @@ -141,7 +162,7 @@ export function darkenOrLighten(baseColor: string, change: number) { * @param colors an array of colors, each of which is an array of [key, color string] * @returns an object mapping keys to colors, with duplicates darkened/lightened to be distinguishable */ -export const dedupColors = (colors: string[][]) => { +export function dedupColors(colors: string[][]) { const dedupedColors = {}; const maxAdjustment = 0.7; // more than this is too drastic and the colors approach black/white for (const [key, clr] of colors) { @@ -150,7 +171,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]) { diff --git a/www/js/components/useLeafletCache.ts b/www/js/components/useLeafletCache.ts new file mode 100644 index 000000000..9c6037c3f --- /dev/null +++ b/www/js/components/useLeafletCache.ts @@ -0,0 +1,10 @@ +import { useState } from 'react'; +export default function useLeafletCache() { + const [cachedMaps, setCachedMaps] = useState(new Map()); + + return { + has: (key: string) => cachedMaps.has(key), + get: (key: string) => cachedMaps.get(key), + set: (key: string, value: any) => setCachedMaps((prev) => new Map(prev.set(key, value))), + }; +} diff --git a/www/js/config/dynamicConfig.ts b/www/js/config/dynamicConfig.ts index 312f02c0b..5843af3d2 100644 --- a/www/js/config/dynamicConfig.ts +++ b/www/js/config/dynamicConfig.ts @@ -1,107 +1,159 @@ -import i18next from "i18next"; -import { displayError, logDebug, logWarn } from "../plugin/logger"; -import { getAngularService } from "../angular-react-helper"; -import { fetchUrlCached } from "../commHelper"; +import i18next from 'i18next'; +import { displayError, logDebug, logWarn } from '../plugin/logger'; +import { fetchUrlCached } from '../services/commHelper'; +import { storageClear, storageGet, storageSet } from '../plugin/storage'; +import { AppConfig } from '../types/appConfigTypes'; -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 storedConfig: AppConfig | null = null; export let configChanged = false; -export const setConfigChanged = (b) => configChanged = b; +export const setConfigChanged = (b) => (configChanged = b); -const _getStudyName = function (connectUrl) { +// used to test multiple configs, not used outside of test +export const _test_resetStoredConfig = () => { + storedConfig = null; +}; + +/** + * @param connectUrl The URL endpoint specified in the config + * @returns The study name (like 'stage' or whatever precedes 'openpath' in the URL), + * or undefined if it can't be determined + */ +function _getStudyName(connectUrl: `https://${string}`) { 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"; - } +/** + * @param config The app config which might be missing 'name' + * @returns Shallow copy of the app config with 'name' filled in if it was missing + */ +function _fillStudyName(config: Partial): AppConfig { + if (config.name) return config as AppConfig; + if (config.server) { + return { ...config, name: _getStudyName(config.server.connectUrl) } as AppConfig; + } else { + return { ...config, name: 'dev' } as AppConfig; } } -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" - } - } +/** + * @param config The app config which might be missing 'survey_info' + * @returns Shallow copy of the app config with the default 'survey_info' filled in if it was missing + */ +function _fillSurveyInfo(config: Partial): AppConfig { + if (config.survey_info) return config as AppConfig; + return { + ...config, + survey_info: { + 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', + }, + } as AppConfig; } -/* 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. - This way they will be available when the user needs them, and we won't have to - fetch them again unless local storage is cleared. */ -const cacheResourcesFromConfig = (config) => { +/** + * @description Fill in any fields that might be missing from the config ('name', 'survey_info') for + * backwards compatibility with old configs + */ +const _backwardsCompatFill = (config: Partial): AppConfig => + _fillSurveyInfo(_fillStudyName(config)); + +/** + * @description 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. + * This way they will be available when the user needs them, and we won't have to + * fetch them again unless local storage is cleared. + * @param config The app config + */ +function cacheResourcesFromConfig(config: AppConfig) { 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')); - fetchUrlCached(survey['formPath']); + if (!survey?.['formPath']) throw new Error(i18next.t('config.survey-missing-formpath')); + fetchUrlCached(survey['formPath'], { cache: 'reload' }); }); } if (config.label_options) { - fetchUrlCached(config.label_options); + fetchUrlCached(config.label_options, { cache: 'reload' }); } } -const readConfigFromServer = async (label) => { - const config = await fetchConfig(label); - logDebug("Successfully found config, result is " + JSON.stringify(config).substring(0, 10)); +/** + * @description Fetch the config from the server, fill in any missing fields, and cache any + * resources referenced in the config + * @param studyLabel The 'label' of the study, like 'open-access' or 'dev-emulator-study' + * @returns The filled in app config + */ +async function readConfigFromServer(studyLabel: string) { + const fetchedConfig = await fetchConfig(studyLabel); + logDebug(`Successfully found config, + fetchedConfig = ${JSON.stringify(fetchedConfig).substring(0, 10)}`); + const filledConfig = _backwardsCompatFill(fetchedConfig); + logDebug(`Applied backwards compat fills, + filledConfig = ${JSON.stringify(filledConfig).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"; - _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); - return config; + cacheResourcesFromConfig(filledConfig); + + logDebug(`Successfully read config, returning config with + version = ${filledConfig.version}; + deployment_name = ${filledConfig.intro?.translated_text?.en?.deployment_name}; + connectionURL = ${fetchedConfig.server ? fetchedConfig.server.connectUrl : 'dev defaults'}`); + return filledConfig; } -const fetchConfig = async (label, alreadyTriedLocal = false) => { - logDebug("Received request to join " + label); - const downloadURL = `https://raw.githubusercontent.com/e-mission/nrel-openpath-deploy-configs/main/configs/${label}.nrel-op.json`; +/** + * @description Fetch the config for a particular study, either from github, or if in dev mode, from + * localhost:9090 and github if that fails + * @param studyLabel The 'label' of the study, like 'open-access' or 'dev-emulator-study' + * @param alreadyTriedLocal Flag for dev environment, if true, will try to fetch from github + * @returns The fetched app config + */ +async function fetchConfig(studyLabel: string, alreadyTriedLocal?: boolean) { + logDebug('Received request to join ' + studyLabel); + let downloadURL = `https://raw.githubusercontent.com/e-mission/nrel-openpath-deploy-configs/main/configs/${studyLabel}.nrel-op.json`; if (!__DEV__ || alreadyTriedLocal) { - logDebug("Fetching config from github"); - const r = await fetch(downloadURL); + logDebug('Fetching config from github'); + const r = await fetch(downloadURL, { cache: 'reload' }); 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"); + return r.json(); // TODO: validate, make sure it has required fields + } 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 (window['cordova'].platformId == 'android') { + downloadURL = `http://10.0.2.2:9090/configs/${studyLabel}.nrel-op.json`; + } else { + downloadURL = `http://localhost:9090/configs/${studyLabel}.nrel-op.json`; + } + const r = await fetch(downloadURL, { cache: 'reload' }); if (!r.ok) throw new Error('Local config not found'); return r.json(); } catch (err) { - logDebug("Local config not found"); - return fetchConfig(label, true); + logDebug('Local config not found'); + return fetchConfig(studyLabel, true); } } } @@ -118,39 +170,47 @@ const fetchConfig = async (label, alreadyTriedLocal = false) => { * * So let's support two separate functions here - extractStudyName and extractSubgroup */ -function extractStudyName(token) { - 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 })); +function extractStudyName(token: string): string { + const tokenParts = token.split('_'); + if (tokenParts.length < 3 || tokenParts.some((part) => part == '')) { + // all tokens must have at least nrelop_[studyname]_[usercode] + // and neither [studyname] nor [usercode] can be blank + 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]; } -function extractSubgroup(token, config) { +function extractSubgroup(token: string, config: AppConfig): string | undefined { 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); + logDebug('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 { - console.log("no subgroups in config, 'default' subgroup found in token "); + logDebug("no subgroups in config, 'default' subgroup found in token "); return tokenParts[2]; } } @@ -160,92 +220,100 @@ 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..."); + */ + logDebug('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 -*/ -function loadNewConfig(newToken, existingVersion = null) { - const KVStore = getAngularService('KVStore'); + * @description Download and load a new config from the server if it is a different version + * @param newToken The new token, which includes parts for the study label, subgroup, and user + * @param existingVersion If the new config's version is the same, we won't update + * @returns boolean representing whether the config was updated or not + */ +export function loadNewConfig(newToken: string, existingVersion?: number): Promise { 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 = KVStore.set(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')); + return Promise.reject(storeError); + }); + }) + .catch((fetchErr) => { + displayError(fetchErr, i18next.t('config.unable-download-config')); + return Promise.reject(fetchErr); + }); } -export function initByUser(urlComponents) { +// exported wrapper around loadNewConfig that includes error handling +export function initByUser(urlComponents: { token: string }) { 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')); + return Promise.reject(fetchErr); + }); } catch (error) { displayError(error, i18next.t('config.invalid-opcode-format')); return Promise.reject(error); } } -export function resetDataAndRefresh() { - const KVStore = getAngularService('KVStore'); - const resetNativePromise = window['cordova'].plugins.BEMUserCache.putRWDocument(CONFIG_PHONE_UI, {}); - const resetKVStorePromise = KVStore.clearAll(); - return Promise.all([resetNativePromise, resetKVStorePromise]) - .then(() => window.location.reload()); -} +/** @description Clears all local and native storage, then triggers a refresh */ +export const resetDataAndRefresh = () => + storageClear({ local: true, native: true }).then(() => window.location.reload()); -export function getConfig() { +/** + * @returns The app config, either from a cached copy, retrieved from local storage, or retrieved + * from user cache with getDocument() + */ +export function getConfig(): Promise { if (storedConfig) return Promise.resolve(storedConfig); - const KVStore = getAngularService('KVStore'); - return KVStore.get(CONFIG_PHONE_UI_KVSTORE).then((config) => { + return storageGet(CONFIG_PHONE_UI_KVSTORE).then((config) => { if (config && Object.keys(config).length) { - logDebug("Got config from KVStore: " + JSON.stringify(config)); - storedConfig = config; - return config; + logDebug('Got config from KVStore: ' + JSON.stringify(config)); + storedConfig = _backwardsCompatFill(config); + return storedConfig; } - 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 = _backwardsCompatFill(config); + return storedConfig; + } + 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 7ad0d37ac..aa87ed1c6 100644 --- a/www/js/config/useImperialConfig.ts +++ b/www/js/config/useImperialConfig.ts @@ -1,6 +1,13 @@ -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'; + +export type ImperialConfig = { + distanceSuffix: string; + speedSuffix: string; + getFormattedDistance: (d: number) => string; + getFormattedSpeed: (s: number) => string; +}; const KM_TO_MILES = 0.621371; const MPS_TO_KMPH = 3.6; @@ -13,30 +20,25 @@ const MPS_TO_KMPH = 3.6; e.g. "7.02 km", "11.3 mph" - if value < 1, round to 2 decimal places e.g. "0.07 mi", "0.75 km" */ -export const formatForDisplay = (value: number): string => { +export function 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; - return Intl.NumberFormat(i18next.language, opts).format(value); + if (value >= 100) opts.maximumFractionDigits = 0; + else if (value >= 1) opts.maximumSignificantDigits = 3; + else opts.maximumFractionDigits = 2; + return Intl.NumberFormat(i18next.resolvedLanguage, opts).format(value); } -const convertDistance = (distMeters: number, imperial: boolean): number => { - if (imperial) - return (distMeters / 1000) * KM_TO_MILES; +export function convertDistance(distMeters: number, imperial: boolean): number { + if (imperial) return (distMeters / 1000) * KM_TO_MILES; return distMeters / 1000; } -const convertSpeed = (speedMetersPerSec: number, imperial: boolean): number => { - if (imperial) - return speedMetersPerSec * MPS_TO_KMPH * KM_TO_MILES; +export function convertSpeed(speedMetersPerSec: number, imperial: boolean): number { + if (imperial) return speedMetersPerSec * MPS_TO_KMPH * KM_TO_MILES; return speedMetersPerSec * MPS_TO_KMPH; } -export function useImperialConfig() { +export function useImperialConfig(): ImperialConfig { const appConfig = useAppConfig(); const [useImperial, setUseImperial] = useState(false); @@ -46,11 +48,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 deleted file mode 100644 index fbac80056..000000000 --- a/www/js/control/AlertBar.jsx +++ /dev/null @@ -1,38 +0,0 @@ -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"; - -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() - }, - }}> - {text} - - - - ); - }; - -export default AlertBar; \ No newline at end of file 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/BluetoothScanSettingRow.tsx b/www/js/control/BluetoothScanSettingRow.tsx new file mode 100644 index 000000000..b32e4cdbf --- /dev/null +++ b/www/js/control/BluetoothScanSettingRow.tsx @@ -0,0 +1,23 @@ +import React, { useState } from 'react'; +import SettingRow from './SettingRow'; +import BluetoothScanPage from '../bluetooth/BluetoothScanPage'; + +const BluetoothScanSettingRow = ({}) => { + const [bluePageVisible, setBluePageVisible] = useState(false); + + async function openPopover() { + setBluePageVisible(true); + } + + return ( + <> + + setBluePageVisible(false)} /> + + ); +}; + +export default BluetoothScanSettingRow; diff --git a/www/js/control/ControlCollectionHelper.tsx b/www/js/control/ControlCollectionHelper.tsx index d93c498a9..9ee0efc6c 100644 --- a/www/js/control/ControlCollectionHelper.tsx +++ b/www/js/control/ControlCollectionHelper.tsx @@ -1,284 +1,347 @@ -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 { displayError, displayErrorMsg, logDebug } from '../plugin/logger'; -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; }; +type AccuracyAction = { text: string; value: 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); + displayError(err, 'error forcing state'); + } } async function accuracy2String(config) { - var accuracy = config.accuracy; - let accuracyOptions = await getAccuracyOptions(); - for (var k in accuracyOptions) { - if (accuracyOptions[k] == accuracy) { - return k; - } + const accuracy = config.accuracy; + let accuracyOptions = await getAccuracyOptions(); + for (let 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 { + const v = await accuracy2String(config); + 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"); - } + displayErrorMsg('Emission does not support this platform: ' + window['cordova'].platformId); } + } } 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"]; - } + 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); + logDebug('setConfig Sucess'); + } catch (err) { + displayError(err, 'Error while setting collection config'); + } } /* -* Simple read/write wrappers -*/ + * Simple read/write wrappers + */ -export const getState = function() { - return window['cordova'].plugins.BEMDataCollection.getState(); -}; +export const getState = () => window['cordova'].plugins.BEMDataCollection.getState(); +const setConfig = (config) => window['cordova'].plugins.BEMDataCollection.setConfig(config); +const getConfig = () => window['cordova'].plugins.BEMDataCollection.getConfig(); +const getAccuracyOptions = () => window['cordova'].plugins.BEMDataCollection.getAccuracyOptions(); +export const forceTransitionWrapper = (transition) => + window['cordova'].plugins.BEMDataCollection.forceTransition(transition); 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: Promise[] = []; + 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 getConfig = function() { - return window['cordova'].plugins.BEMDataCollection.getConfig(); -}; -const getAccuracyOptions = function() { - return window['cordova'].plugins.BEMDataCollection.getAccuracyOptions(); -}; - -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]}); +function formatConfigForDisplay(config, accuracyOptions) { + const retVal: { key: string; val: string }[] = []; + for (let prop in config) { + if (prop == 'accuracy') { + for (let 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 [ 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: Promise[] = []; + promiseList.push(getConfig()); + promiseList.push(getAccuracyOptions()); + const [tempConfig, tempAccuracyOptions] = await Promise.all(promiseList); + setLocalConfig(tempConfig); + setAccuracyActions(formatAccuracyForActions(tempAccuracyOptions)); + return formatConfigForDisplay(tempConfig, tempAccuracyOptions); + } - const formatAccuracyForActions = function(accuracyOptions) { - let tempAccuracyActions = []; - for (var name in accuracyOptions) { - tempAccuracyActions.push({text: name, value: accuracyOptions[name]}); - } - return tempAccuracyActions; + useEffect(() => { + getCollectionSettings(); + }, [editVis]); + + function formatAccuracyForActions(accuracyOptions) { + let tempAccuracyActions: AccuracyAction[] = []; + for (let 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() { + logDebug('new config = ' + JSON.stringify(localConfig)); + try { + let set = await setConfig(localConfig); + setEditVis(false); + } catch (err) { + displayError(err, 'Error while setting collection config'); } + } - const onToggle = function(config_key) { - let tempConfig = {...localConfig}; - tempConfig[config_key] = !localConfig[config_key]; - setLocalConfig(tempConfig); - } + function onToggle(config_key) { + let tempConfig = { ...localConfig } as CollectionConfig; + tempConfig[config_key] = !(localConfig as CollectionConfig)[config_key]; + setLocalConfig(tempConfig); + } - const onChooseAccuracy = function(accuracyOption) { - let tempConfig = {...localConfig}; - tempConfig.accuracy = accuracyOption.value; - setLocalConfig(tempConfig); - } + function onChooseAccuracy(accuracyOption) { + let tempConfig = { ...localConfig } as CollectionConfig; + tempConfig.accuracy = accuracyOption.value; + setLocalConfig(tempConfig); + } - const onChangeText = function(newText, config_key) { - let tempConfig = {...localConfig}; - tempConfig[config_key] = parseInt(newText); - setLocalConfig(tempConfig); - } + function onChangeText(newText, config_key) { + let tempConfig = { ...localConfig } as CollectionConfig; + 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.tsx similarity index 78% rename from www/js/control/ControlDataTable.jsx rename to www/js/control/ControlDataTable.tsx index 796b057ec..ea53bbd52 100644 --- a/www/js/control/ControlDataTable.jsx +++ b/www/js/control/ControlDataTable.tsx @@ -1,18 +1,17 @@ -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); return ( //rows require unique keys! - {controlData?.map((e) => - + {controlData?.map((e) => ( + {e.key} {e.val.toString()} - )} + ))} ); }; @@ -23,7 +22,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 490672c4d..bdb565f61 100644 --- a/www/js/control/ControlSyncHelper.tsx +++ b/www/js/control/ControlSyncHelper.tsx @@ -1,284 +1,281 @@ -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 { useTranslation } from 'react-i18next'; +import { settingStyles } from './ProfileSettings'; +import ActionMenu from '../components/ActionMenu'; +import SettingRow from './SettingRow'; +import { AlertManager } from '../components/AlertBar'; +import { addStatEvent, statKeys } from '../plugin/clientStats'; +import { updateUser } from '../services/commHelper'; +import { displayError, logDebug, logWarn } from '../plugin/logger'; +import { DateTime } from 'luxon'; /* -* BEGIN: Simple read/write wrappers -*/ -export function forcePluginSync() { - return window.cordova.plugins.BEMServerSync.forceSync(); -}; + * BEGIN: Simple read/write wrappers + */ +export const forcePluginSync = () => window['cordova'].plugins.BEMServerSync.forceSync(); +const setConfig = (config) => window['cordova'].plugins.BEMServerSync.setConfig(config); +const getConfig = () => window['cordova'].plugins.BEMServerSync.getConfig(); -const formatConfigForDisplay = (configToFormat) => { - var formatted = []; - for (let prop in configToFormat) { - formatted.push({'key': prop, 'val': configToFormat[prop]}); - } - return formatted; +function formatConfigForDisplay(configToFormat) { + const formatted: any[] = []; + 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 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"; - } -} - -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 ClientStats = getAngularService('ClientStats'); - const Logger = getAngularService('Logger'); +export const ForceSyncRow = ({ getState }) => { + const { t } = useTranslation(); + const { colors } = useTheme(); - const [dataPendingVis, setDataPendingVis] = useState(false); - const [dataPushedVis, setDataPushedVis] = useState(false); + const [dataPendingVis, setDataPendingVis] = useState(false); - async function forceSync() { - try { - let addedEvent = ClientStats.addEvent(ClientStats.getStatKeys().BUTTON_FORCE_SYNC); - console.log("Added "+ClientStats.getStatKeys().BUTTON_FORCE_SYNC+" event"); + async function forceSync() { + try { + let addedEvent = await addStatEvent(statKeys.BUTTON_FORCE_SYNC); + 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 + */ + const sensorKey = 'statemachine/transition'; + let sensorDataList = await window['cordova'].plugins.BEMUserCache.getAllMessages( + sensorKey, + true, + ); - 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); - } - }; + // If everything has been pushed, we should + // have no more trip end transitions left + let isTripEnd = (entry) => entry.metadata == getEndTransitionKey(); + let syncLaunchedCalls = sensorDataList.filter(isTripEnd); + let syncPending = syncLaunchedCalls.length > 0; + logDebug(`sensorDataList.length = ${sensorDataList.length}, + syncLaunchedCalls.length = ${syncLaunchedCalls.length}, + syncPending? = ${syncPending}`); - const getStartTransitionKey = function() { - if(window.cordova.platformId == 'android') { - return "local.transition.exited_geofence"; - } - else if(window.cordova.platformId == 'ios') { - return "T_EXITED_GEOFENCE"; - } + if (syncPending) { + logDebug('data is pending, showing confirm dialog'); + setDataPendingVis(true); //consent handling in modal + } else { + AlertManager.addMessage({ text: 'all data pushed!' }); + } + } catch (error) { + displayError(error, 'Error while forcing sync'); } + } - const getEndTransitionKey = function() { - if(window.cordova.platformId == 'android') { - return "local.transition.stopped_moving"; - } - else if(window.cordova.platformId == 'ios') { - return "T_TRIP_ENDED"; - } + function getStartTransitionKey() { + 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"; - } + function getEndTransitionKey() { + 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; + function getOngoingTransitionState() { + 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(); - }; + async function getTransition(transKey) { + const 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'] = DateTime.now().toSeconds(); + return entry_data; + } - return ( - <> - - + async function endForceSync() { + /* First, quickly start and end the trip. Let's listen to the promise + * result for start so that we ensure ordering */ + const 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(); + } - {/* dataPending */} - setDataPendingVis(false)} transparent={true}> - setDataPendingVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - {t('data pending for push')} - - - - - - + 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 CommHelper = getAngularService("CommHelper"); - const Logger = getAngularService("Logger"); + const { t } = useTranslation(); + const { colors } = useTheme(); - const [ localConfig, setLocalConfig ] = useState(); - const [ intervalVis, setIntervalVis ] = useState(false); + 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); - } + /* + * Functions to read and format values for display + */ + async function getSyncSettings() { + let tempConfig = await getConfig(); + setLocalConfig(tempConfig); + } - useEffect(() => { - getSyncSettings(); - }, [editVis]) + 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} - ] + 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!! - CommHelper.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); - } + /* + * Functions to edit and save values + */ + async function saveAndReload() { + logDebug('saveAndReload: new config = ' + JSON.stringify(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 as SyncConfig).sync_interval, + }); + } catch (err) { + displayError(err, 'Error while setting sync config'); } + } + function onChooseInterval(interval) { + let tempConfig = { ...localConfig } as SyncConfig; + tempConfig.sync_interval = interval.value; + setLocalConfig(tempConfig); + } - const onChooseInterval = function(interval) { - let tempConfig = {...localConfig}; - tempConfig.sync_interval = interval.value; - setLocalConfig(tempConfig); - } + function onTogglePush() { + let tempConfig = { ...localConfig } as SyncConfig; + tempConfig.ios_use_remote_push = !(localConfig as SyncConfig).ios_use_remote_push; + 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 + + + ); + } - /* - * 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} - - - - - - - + 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; \ No newline at end of file + {}}> + + ); +}; + +export default ControlSyncHelper; diff --git a/www/js/control/DataDatePicker.tsx b/www/js/control/DataDatePicker.tsx index 83e0986b2..308d940e3 100644 --- a/www/js/control/DataDatePicker.tsx +++ b/www/js/control/DataDatePicker.tsx @@ -1,15 +1,13 @@ -// 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 +/* This date picker element is set up to handle the "download data from day" in ProfileSettings. + Later, we may consider changing this to a date range instead of a single day */ -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 { getMyData } from '../services/controlHelper'; -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 onDismiss = React.useCallback(() => { setOpen(false); }, [setOpen]); @@ -18,29 +16,29 @@ const DataDatePicker = ({date, setDate, open, setOpen, minDate}) => { (params) => { setOpen(false); setDate(params.date); - ControlHelper.getMyData(params.date); + 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 deleted file mode 100644 index be02dd6d3..000000000 --- a/www/js/control/DemographicsSettingRow.jsx +++ /dev/null @@ -1,35 +0,0 @@ -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 = ({ }) => { - - const [surveyModalVisible, setSurveyModalVisible] = useState(false); - const [prevSurveyResponse, setPrevSurveyResponse] = useState(null); - - function openPopover() { - return loadPreviousResponseForSurvey(DEMOGRAPHIC_SURVEY_DATAKEY).then((lastSurvey) => { - if (lastSurvey?.data?.xmlResponse) { - setPrevSurveyResponse(lastSurvey.data.xmlResponse); - setSurveyModalVisible(true); - } - }); - } - - 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/DemographicsSettingRow.tsx b/www/js/control/DemographicsSettingRow.tsx new file mode 100644 index 000000000..d3aa24d1c --- /dev/null +++ b/www/js/control/DemographicsSettingRow.tsx @@ -0,0 +1,39 @@ +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 = ({}) => { + const [surveyModalVisible, setSurveyModalVisible] = useState(false); + const [prevSurveyResponse, setPrevSurveyResponse] = useState(undefined); + + function openPopover() { + return loadPreviousResponseForSurvey(DEMOGRAPHIC_SURVEY_DATAKEY).then((lastSurvey) => { + if (lastSurvey?.data?.xmlResponse) { + setPrevSurveyResponse(lastSurvey.data.xmlResponse); + setSurveyModalVisible(true); + } + }); + } + + 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 deleted file mode 100644 index 2f8bb8ef1..000000000 --- a/www/js/control/ExpandMenu.jsx +++ /dev/null @@ -1,33 +0,0 @@ -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"; - -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 handlePress = () => setExpanded(!expanded); - - return ( - - {props.children} - - ); -}; -const styles = StyleSheet.create({ - section: (surfaceColor) => ({ - justifyContent: 'space-between', - backgroundColor: surfaceColor, - margin: 1, - }), -}); - -export default ExpansionSection; \ No newline at end of file diff --git a/www/js/control/ExpandMenu.tsx b/www/js/control/ExpandMenu.tsx new file mode 100644 index 000000000..d1a744b5a --- /dev/null +++ b/www/js/control/ExpandMenu.tsx @@ -0,0 +1,35 @@ +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'; + +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 handlePress = () => setExpanded(!expanded); + + return ( + + {props.children} + + ); +}; +const styles = StyleSheet.create({ + section: (surfaceColor) => ({ + justifyContent: 'space-between', + backgroundColor: surfaceColor, + margin: 1, + }), +}); + +export default ExpansionSection; diff --git a/www/js/control/LogPage.tsx b/www/js/control/LogPage.tsx index e33d2f9a3..96ef290b3 100644 --- a/www/js/control/LogPage.tsx +++ b/www/js/control/LogPage.tsx @@ -1,153 +1,166 @@ -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 { 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 { DateTime } from 'luxon'; +import { AlertManager } from '../components/AlertBar'; +import { sendEmail } from './emailService'; +import { displayError, logDebug } from '../plugin/logger'; +import NavBar from '../components/NavBar'; + +type LoadStats = { currentStart: number; gotMaxIndex: boolean; reachedEnd: boolean }; + +const LogPage = ({ pageVis, setPageVis }) => { + const { t } = useTranslation(); + const { colors } = useTheme(); + + const [loadStats, setLoadStats] = useState(); + const [entries, setEntries] = useState([]); + const [isFetching, setIsFetching] = useState(false); + + const RETRIEVE_COUNT = 100; + + //when opening the modal, load the entries + useEffect(() => { + refreshEntries(); + }, [pageVis]); + + async function refreshEntries() { + try { + let maxIndex = await window['Logger'].getMaxIndex(); + logDebug('Logger 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); + displayError(error, errorString); + AlertManager.addMessage({ text: errorString }); + } finally { + addEntries(); } + } - const moreDataCanBeLoaded = useMemo(() => { - return loadStats?.gotMaxIndex && !loadStats?.reachedEnd; - }, [loadStats]) + 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(); + function clear() { + window?.['Logger'].clearAll(); + window?.['Logger'].log( + window['Logger'].LEVEL_INFO, + 'Finished clearing entries from unified log', + ); + refreshEntries(); + } + + async function 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); + logDebug('addEntries: entry list size = ' + entries.length); + setIsFetching(false); + } catch (error) { + let errStr = t('errors.while-log-messages') + JSON.stringify(error, null, 2); + displayError(error, errStr); + AlertManager.addMessage({ text: errStr }); + 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); - } + } + + function processEntries(entryList) { + let tempEntries: any[] = []; + let tempLoadStats: LoadStats = { ...loadStats } as LoadStats; + entryList.forEach((e) => { + e.fmt_time = DateTime.fromSeconds(e.ts).toLocaleString(DateTime.DATETIME_MED); + tempEntries.push(e); + }); + if (entryList.length == 0) { + logDebug('LogPage reached the end of the scrolling'); + tempLoadStats.reachedEnd = true; + } else { + tempLoadStats.currentStart = entryList[entryList.length - 1].ID; + logDebug('LogPage new start index = ' + loadStats?.currentStart); } - - 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 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); + } + + function emailLog() { + 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 deleted file mode 100644 index 21ce227c0..000000000 --- a/www/js/control/PopOpCode.jsx +++ /dev/null @@ -1,79 +0,0 @@ -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"; - -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 copyText = function(textToCopy){ - navigator.clipboard.writeText(textToCopy).then(() => { - setCopyAlertvis(true); - }) - } - - 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 - } - }); - -export default PopOpCode; \ No newline at end of file diff --git a/www/js/control/PopOpCode.tsx b/www/js/control/PopOpCode.tsx new file mode 100644 index 000000000..3687d513b --- /dev/null +++ b/www/js/control/PopOpCode.tsx @@ -0,0 +1,82 @@ +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 { AlertManager } from '../components/AlertBar'; +import { settingStyles } from './ProfileSettings'; + +const PopOpCode = ({ visibilityValue, tokenURL, action, setVis }) => { + const { t } = useTranslation(); + const { colors } = useTheme(); + + const opcodeList = tokenURL.split('='); + const opcode = opcodeList[opcodeList.length - 1]; + + function copyText(textToCopy) { + navigator.clipboard.writeText(textToCopy).then(() => { + AlertManager.addMessage({ msgKey: 'Copied to clipboard!' }); + }); + } + + let copyButton; + if (window['cordova'].platformId == 'ios') { + copyButton = ( + { + copyText(opcode); + }} + 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, + }, +}); + +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 deleted file mode 100644 index e79a95d8d..000000000 --- a/www/js/control/ProfileSettings.jsx +++ /dev/null @@ -1,517 +0,0 @@ -import React, { useState, useEffect, useContext } from "react"; -import { Modal, StyleSheet, ScrollView } from "react-native"; -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"; -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 AppStatusModal from "./AppStatusModal"; -import PrivacyPolicyModal from "./PrivacyPolicyModal"; -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"; - -//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 UploadHelper = getAngularService('UploadHelper'); - const EmailHelper = getAngularService('EmailHelper'); - const KVStore = getAngularService('KVStore'); - const NotificationScheduler = getAngularService('NotificationScheduler'); - const ControlHelper = getAngularService('ControlHelper'); - const ClientStats = getAngularService('ClientStats'); - const StartPrefs = getAngularService('StartPrefs'); - - //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 [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 [appVersion, setAppVersion] = useState(""); - const [uiConfig, setUiConfig] = useState({}); - const [consentDoc, setConsentDoc] = useState({}); - const [dumpDate, setDumpDate] = useState(new Date()); - - 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(); - setAppVersion(ClientStats.getAppVersion()); - } - - //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); - } - - //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); - }); - }; - - //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; - } - setAuthSettings(newAuthSettings); - }; - - //methods that control the settings - const uploadLog = function () { - UploadHelper.uploadFile("loggerDB") - }; - - 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(); - }); - } - } - - 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); - } - } - } - - 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() { - StartPrefs.getConsentDocument().then(function(resultDoc){ - setConsentDoc(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 = ; - } - - 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}> - - - {/* 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 */} - {}}> - - {/* 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')} - - - - - - - - {/* handle consent */} - setConsentVis(false)} transparent={true}> - setConsentVis(false)} - style={settingStyles.dialog(colors.elevation.level3)}> - {t('general-settings.consented-to', {protocol_id: consentDoc.protocol_id, 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; diff --git a/www/js/control/ProfileSettings.tsx b/www/js/control/ProfileSettings.tsx new file mode 100644 index 000000000..ab381e594 --- /dev/null +++ b/www/js/control/ProfileSettings.tsx @@ -0,0 +1,677 @@ +import React, { useState, useEffect, useContext, useRef } from 'react'; +import { Modal, StyleSheet, ScrollView } from 'react-native'; +import { Dialog, Button, useTheme, Text, Appbar, TextInput } from 'react-native-paper'; +import { useTranslation } from 'react-i18next'; +import ExpansionSection from './ExpandMenu'; +import SettingRow from './SettingRow'; +import ControlDataTable from './ControlDataTable'; +import DemographicsSettingRow from './DemographicsSettingRow'; +import BluetoothScanSettingRow from './BluetoothScanSettingRow'; +import PopOpCode from './PopOpCode'; +import ReminderTime from './ReminderTime'; +import useAppConfig from '../useAppConfig'; +import { AlertManager } from '../components/AlertBar'; +import DataDatePicker from './DataDatePicker'; +import PrivacyPolicyModal from './PrivacyPolicyModal'; +import { sendEmail } from './emailService'; +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 { loadNewConfig, 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 { displayError, displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; +import { fetchOPCode, getSettings } from '../services/controlHelper'; +import { + updateScheduledNotifs, + getScheduledNotifs, + getReminderPrefs, + setReminderPrefs, +} from '../splash/notifScheduler'; +import { DateTime } from 'luxon'; +import { AppConfig } from '../types/appConfigTypes'; +import NavBar, { NavBarButton } from '../components/NavBar'; + +//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); + + 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 [forceStateVis, setForceStateVis] = useState(false); + const [logoutVis, setLogoutVis] = useState(false); + const [noConsentVis, setNoConsentVis] = 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(undefined); + const [consentDoc, setConsentDoc] = useState({}); + const [dumpDate, setDumpDate] = useState(new Date()); + const [uploadReason, setUploadReason] = useState(''); + const appVersion = useRef(); + + 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' }, + ]; + + // used for scheduling notifs + let scheduledPromise = new Promise((rs) => rs()); + const [isScheduling, setIsScheduling] = useState(false); + + useEffect(() => { + //added appConfig.name needed to be defined because appConfig was defined but empty + if (appConfig && appConfig.name) { + whenReady(appConfig); + } + }, [appConfig]); + + function refreshScreen() { + 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]); + + function whenReady(newAppConfig: AppConfig) { + const tempUiConfig = newAppConfig; + + // 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'; + } + + if (tempUiConfig.reminderSchemes) { + // Update the scheduled notifs + updateScheduledNotifs( + tempUiConfig.reminderSchemes, + isScheduling, + setIsScheduling, + scheduledPromise, + ) + .then(() => { + logDebug('updated scheduled notifs'); + }) + .catch((err) => { + displayErrorMsg('Error while updating scheduled notifs', err); + }); + } + + setUiConfig(tempUiConfig); + refreshScreen(); + } + + async function refreshCollectSettings() { + logDebug('refreshCollectSettings: collectSettings = ' + JSON.stringify(collectSettings)); + const newCollectSettings: any = {}; + + // // 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); + } + + //ensure ui table updated when editor closes + useEffect(() => { + if (editCollectionVis == false) { + setTimeout(() => { + logDebug('closed editor, time to refreshCollectSettings'); + refreshCollectSettings(); + }, 1000); + } + }, [editCollectionVis]); + + async function refreshNotificationSettings() { + logDebug(`about to refreshNotificationSettings, + notificationSettings = ${JSON.stringify(notificationSettings)}`); + const newNotificationSettings: any = {}; + + if (uiConfig?.reminderSchemes) { + let promiseList: Promise[] = []; + promiseList.push( + getReminderPrefs(uiConfig.reminderSchemes, isScheduling, setIsScheduling, scheduledPromise), + ); + promiseList.push(getScheduledNotifs(isScheduling, scheduledPromise)); + let resultList = await Promise.all(promiseList); + const prefs = resultList[0]; + const scheduledNotifs = resultList[1]; + logDebug(`prefs - scheduled notifs: + ${JSON.stringify(prefs)}\n - \n${JSON.stringify(scheduledNotifs)}`); + + const m = DateTime.fromFormat(prefs.reminder_time_of_day, 'HH:mm'); + newNotificationSettings.prefReminderTimeVal = m.toJSDate(); + newNotificationSettings.prefReminderTime = m.toFormat('t'); + newNotificationSettings.prefReminderTimeOnLoad = prefs.reminder_time_of_day; + newNotificationSettings.scheduledNotifs = scheduledNotifs; + } + + logDebug(`notification settings before - after: + ${JSON.stringify(notificationSettings)} - ${JSON.stringify(newNotificationSettings)}`); + setNotificationSettings(newNotificationSettings); + } + + async function getSyncSettings() { + const newSyncSettings: any = {}; + getHelperSyncSettings().then((showConfig) => { + newSyncSettings.show_config = showConfig; + setSyncSettings(newSyncSettings); + logDebug('sync settings are: ' + JSON.stringify(syncSettings)); + }); + } + + //update sync settings in the table when close editor + useEffect(() => { + getSyncSettings(); + }, [editSync]); + + async function getConnectURL() { + getSettings().then( + (response) => { + const newConnectSettings: any = {}; + logDebug('getConnectURL: got response.connectUrl = ' + response.connectUrl); + newConnectSettings.url = response.connectUrl; + setConnectSettings(newConnectSettings); + }, + (error) => { + displayError(error, 'While getting connect url'); + }, + ); + } + + async function getOPCode() { + const newAuthSettings: any = {}; + const opcode = await fetchOPCode(); + if (opcode == null) { + newAuthSettings.opcode = 'Not logged in'; + } else { + newAuthSettings.opcode = opcode; + } + setAuthSettings(newAuthSettings); + } + + //methods that control the settings + function uploadLog() { + if (uploadReason != '') { + let reason = uploadReason; + uploadFile('loggerDB', reason); + setUploadVis(false); + } + } + + async function updatePrefReminderTime(storeNewVal = true, newTime) { + if (!uiConfig?.reminderSchemes) + return logWarn('In updatePrefReminderTime, no reminderSchemes yet, skipping'); + if (storeNewVal) { + const dt = DateTime.fromJSDate(newTime); + // store in HH:mm + setReminderPrefs( + { reminder_time_of_day: dt.toFormat('HH:mm') }, + uiConfig.reminderSchemes, + isScheduling, + setIsScheduling, + scheduledPromise, + ).then(() => { + refreshNotificationSettings(); + }); + } + } + + function dummyNotification() { + window['cordova'].plugins.notification.local.addActions('dummy-actions', [ + { id: 'action', title: 'Yes' }, + { id: 'cancel', title: 'No' }, + ]); + window['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(() => { + refreshCollectSettings(); + }, 1500); + } + + async function refreshConfig() { + AlertManager.addMessage({ text: t('control.refreshing-app-config') }); + const updated = await loadNewConfig(authSettings.opcode, appConfig?.version); + if (updated) { + window.location.reload(); + } else { + AlertManager.addMessage({ text: t('control.already-up-to-date') }); + } + } + + //Platform.OS returns "web" now, but could be used once it's fully a Native app + //for now, use window.cordova.platformId + + function parseState(state) { + logDebug(`parseState: state = ${state}; + platformId = ${window['cordova'].platformId}`); + if (state) { + if (window['cordova'].platformId == 'android') { + logDebug('platform ANDROID; parsed state will be ' + state.substring(12)); + return state.substring(12); + } else if (window['cordova'].platformId == 'ios') { + logDebug('platform IOS; parsed state will be ' + state.substring(6)); + return state.substring(6); + } + } + } + + async function invalidateCache() { + window['cordova'].plugins.BEMUserCache.invalidateAllCache().then( + (result) => { + logDebug('invalidateCache: result = ' + JSON.stringify(result)); + AlertManager.addMessage({ text: `success -> ${result}` }); + }, + (error) => { + displayError(error, 'while invalidating cache, error->'); + }, + ); + } + + //in ProfileSettings in DevZone (above two functions are helpers) + async function checkConsent() { + getConsentDocument().then( + (resultDoc) => { + setConsentDoc(resultDoc); + logDebug(`In profile settings, consent doc found = ${JSON.stringify(resultDoc)}`); + if (resultDoc == null) { + setNoConsentVis(true); + } else { + setConsentVis(true); + } + }, + (error) => { + displayError(error, 'Error reading consent document from cache'); + }, + ); + } + + const onSelectState = (stateObject) => { + forceTransition(stateObject.transition); + }; + + //conditional creation of setting sections + + let logUploadSection; + if (appConfig?.profile_controls?.support_upload) { + logUploadSection = ( + setUploadVis(true)}> + ); + } + + let timePicker; + let notifSchedule; + if (appConfig?.reminderSchemes) { + timePicker = ( + + ); + notifSchedule = ( + <> + {}}> + + + ); + } + + return ( + <> + + + setLogoutVis(true)}> + {t('control.log-out')} + + + + + setOpCodeVis(true)} + desc={authSettings.opcode} + descStyle={settingStyles.monoDesc}> + + setPrivacyVis(true)}> + {timePicker} + + setPermissionsPopupVis(true)}> + + setDateDumpVis(true)}> + {logUploadSection} + sendEmail('loggerDB')}> + + + + + + + + {notifSchedule} + + setNukeVis(true)}> + setForceStateVis(true)}> + setShowingLog(true)}> + setShowingSensed(true)}> + + + + + + {}} + desc={appVersion.current}> + + + {/* menu for "nuke data" */} + setNukeVis(false)} transparent={true}> + setNukeVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + {t('general-settings.clear-data')} + + + + + + + + + + + + {/* 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; 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..78971e9d0 100644 --- a/www/js/control/SensedPage.tsx +++ b/www/js/control/SensedPage.tsx @@ -1,91 +1,90 @@ -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 { useTranslation } from 'react-i18next'; import { FlashList } from '@shopify/flash-list'; -import moment from "moment"; +import { DateTime } from 'luxon'; +import { sendEmail } from './emailService'; +import NavBar from '../components/NavBar'; -const SensedPage = ({pageVis, setPageVis}) => { - const { t } = useTranslation(); - const { colors } = useTheme(); - const EmailHelper = getAngularService('EmailHelper'); +const SensedPage = ({ pageVis, setPageVis }) => { + const { t } = useTranslation(); + const { colors } = useTheme(); - /* Let's keep a reference to the database for convenience */ - const [ DB, setDB ]= useState(); - const [ entries, setEntries ] = useState([]); + const [entries, setEntries] = useState([]); - const emailCache = function() { - EmailHelper.sendEmail("userCacheDB"); + async function updateEntries() { + //hardcoded function and keys after eliminating bit-rotted options + let userCacheFn = window['cordova'].plugins.BEMUserCache.getAllMessages; + let userCacheKey = 'statemachine/transition'; + try { + let entryList = await userCacheFn(userCacheKey, true); + let tempEntries: any[] = []; + entryList.forEach((entry) => { + entry.metadata.write_fmt_time = DateTime.fromSeconds(entry.metadata.write_ts) + .setZone(entry.metadata.time_zone) + .toLocaleString(DateTime.DATETIME_MED); + 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()} /> + sendEmail('userCacheDB')} /> + - - 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 deleted file mode 100644 index 473a45d7f..000000000 --- a/www/js/control/SettingRow.jsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from "react"; -import { StyleSheet } from 'react-native'; -import { List, Switch, useTheme } from 'react-native-paper'; -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 - - let rightComponent; - if (iconName) { - rightComponent = ; - } else { - rightComponent = ; - } - let descriptionText; - if(desc) { - descriptionText = {desc}; - } else { - descriptionText = ""; - } - - 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, - }, - }); - -export default SettingRow; diff --git a/www/js/control/SettingRow.tsx b/www/js/control/SettingRow.tsx new file mode 100644 index 000000000..abfc30511 --- /dev/null +++ b/www/js/control/SettingRow.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; +import { List, Switch, useTheme } from 'react-native-paper'; +import { useTranslation } from 'react-i18next'; + +type Props = { + textKey: string; + iconName?: string; + action: any; + desc?: string; + switchValue?: boolean; + descStyle?: any; +}; +const SettingRow = ({ textKey, iconName, action, desc, switchValue, descStyle }: Props) => { + const { t } = useTranslation(); // Access translations + const { colors } = useTheme(); // Get theme colors + + let rightComponent; + if (iconName) { + rightComponent = ; + } else { + 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, + }, +}); + +export default SettingRow; diff --git a/www/js/control/emailService.js b/www/js/control/emailService.js deleted file mode 100644 index 0374adf5a..000000000 --- a/www/js/control/emailService.js +++ /dev/null @@ -1,97 +0,0 @@ -'use strict'; - -import angular from 'angular'; - -angular.module('emission.services.email', ['emission.plugin.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 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"; - - // 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.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 + "!") - } - - 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; - }); - }); - }; -}); diff --git a/www/js/control/emailService.ts b/www/js/control/emailService.ts new file mode 100644 index 000000000..3a6e8a5c5 --- /dev/null +++ b/www/js/control/emailService.ts @@ -0,0 +1,52 @@ +import i18next from 'i18next'; +import { logDebug, logInfo, logWarn } from '../plugin/logger'; + +async function hasAccount(): Promise { + return new Promise((resolve, reject) => { + window['cordova'].plugins['email'].hasAccount((hasAct) => { + resolve(hasAct); + }); + }); +} + +export async function sendEmail(database: string) { + let parentDir = 'unknown'; + + if (window['cordova'].platformId == 'ios' && !(await hasAccount())) { + alert(i18next.t('email-service.email-account-not-configured')); + return; + } + + if (window['cordova'].platformId == 'android') { + parentDir = 'app://databases'; + } + + if (window['cordova'].platformId == 'ios') { + alert(i18next.t('email-service.email-account-mail-app')); + logDebug(window['cordova'].file.dataDirectory); + parentDir = window['cordova'].file.dataDirectory + '../LocalDatabase'; + } + + if (parentDir === 'unknown') { + alert('parentDir unexpectedly = ' + parentDir + '!'); + } + + logInfo('Going to email ' + database); + parentDir = parentDir + '/' + database; + + alert(i18next.t('email-service.going-to-email', { parentDir: parentDir })); + + let emailConfig = `k.shankari@nrel.gov`; + + let emailData = { + to: emailConfig, + 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(emailData, () => { + logWarn(`Email app closed while sending, + emailData = ${JSON.stringify(emailData)}`); + }); +} diff --git a/www/js/control/uploadService.js b/www/js/control/uploadService.js deleted file mode 100644 index 6f95503c1..000000000 --- a/www/js/control/uploadService.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/control/uploadService.ts b/www/js/control/uploadService.ts new file mode 100644 index 000000000..ca65fac42 --- /dev/null +++ b/www/js/control/uploadService.ts @@ -0,0 +1,134 @@ +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 (resolve, reject) => { + logInfo('About to get email config'); + let url: string[] = []; + 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'])); + 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'); +} + +function onUploadError(err) { + displayError(err, 'Error while uploading log'); +} + +function readDBFile(parentDir, database, callbackFn) { + return new Promise((resolve, reject) => { + window['resolveLocalFileSystemURL'](parentDir, (fs) => { + logDebug('resolving file system as ' + JSON.stringify(fs)); + fs.filesystem.root.getFile( + fs.fullPath + database, + null, + (fileEntry) => { + logDebug('fileEntry = ' + JSON.stringify(fileEntry)); + fileEntry.file((file) => { + logDebug('file = ' + JSON.stringify(file)); + const reader = new FileReader(); + + reader.onprogress = (report) => { + logDebug('Current progress is ' + JSON.stringify(report)); + if (callbackFn != undefined) { + callbackFn((report.loaded * 100) / report.total); + } + }; + + reader.onerror = (error) => { + logDebug('Error while reading file ' + JSON.stringify(reader.error)); + reject({ error: { message: reader.error } }); + }; + + reader.onload = () => { + logDebug(`Successful file read with ${reader.result?.['byteLength']} characters`); + resolve(new DataView(reader.result as ArrayBuffer)); + }; + + 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, + }); +}; + +//only export of this file, used in ProfileSettings and passed the argument (""loggerDB"") +export async function uploadFile(database, reason) { + try { + let uploadConfig = await getUploadConfig(); + let 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 + '!'); + } + + logInfo('Going to upload ' + database); + try { + let binString: any = await readDBFile(parentDir, database, undefined); + logDebug('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); + } + } catch (error) { + onReadError(error); + } +} diff --git a/www/js/controllers.js b/www/js/controllers.js deleted file mode 100644 index 7efc26c09..000000000 --- a/www/js/controllers.js +++ /dev/null @@ -1,91 +0,0 @@ -'use strict'; - -import angular from 'angular'; - -angular.module('emission.controllers', ['emission.splash.startprefs', - 'emission.splash.pushnotify', - 'emission.splash.storedevicesettings', - 'emission.splash.localnotify', - 'emission.splash.remotenotify', - 'emission.stats.clientstats']) - -.controller('RootCtrl', function($scope) {}) - -.controller('DashCtrl', function($scope) {}) - -.controller('SplashCtrl', function($scope, $state, $interval, $rootScope, - StartPrefs, PushNotify, StoreDeviceSettings, - LocalNotify, RemoteNotify, ClientStats) { - 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)); - ClientStats.addReading(ClientStats.getStatKeys().STATE_CHANGED, - fromState.name + '-2-' + toState.name).then(function() {}, function() {}); - }); - $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)); - ClientStats.addError(ClientStats.getStatKeys().STATE_CHANGED, - fromState.name + '-2-' + toState.name+ "_" + error).then(function() {}, function() {}); - }); - $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 - ClientStats.addError(ClientStats.getStatKeys().STATE_CHANGED, - fromState.name + '-2-' + unfoundState.name).then(function() {}, function() {}); - }); - - 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 - StartPrefs.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) { - //}); - - $scope.chats = Chats.all(); - $scope.remove = function(chat) { - Chats.remove(chat); - }; -}) - -.controller('ChatDetailCtrl', function($scope, $stateParams, Chats) { - $scope.chat = Chats.get($stateParams.chatId); -}) - -.controller('AccountCtrl', function($scope) { - $scope.settings = { - enableFriends: true - }; -}); diff --git a/www/js/customEventHandler.ts b/www/js/customEventHandler.ts new file mode 100644 index 000000000..a30a41349 --- /dev/null +++ b/www/js/customEventHandler.ts @@ -0,0 +1,55 @@ +/** + * since react doesn't quite support custom events, writing our own handler + * having the ability to broadcast and emit events prevents files from being tightly coupled + * if we want something else to happen when an event is emitted, we can just listen for it + * instead of having to change the code at the point the event is emitted + * + * looser coupling = point of broadcast doesn't 'know' what is triggered by that event + * leads to more extensible code + * consistent event names help us know what happens when + * + * code based on: https://blog.logrocket.com/using-custom-events-react/ + */ + +import { logDebug } from './plugin/logger'; + +/** + * central source for event names + */ +export const EVENTS = { + CLOUD_NOTIFICATION_EVENT: 'cloud:push:notification', + CONSENTED_EVENT: 'data_collection_consented', + INTRO_DONE_EVENT: 'intro_done', +}; + +/** + * @function starts listening to an event + * @param eventName {string} the name of the event + * @param listener event listener, function to execute on event + */ +export function subscribe(eventName: string, listener) { + logDebug('adding ' + eventName + ' listener'); + document.addEventListener(eventName, listener); +} + +/** + * @function stops listening to an event + * @param eventName {string} the name of the event + * @param listener event listener, function to execute on event + */ +export function unsubscribe(eventName: string, listener) { + logDebug('removing ' + eventName + ' listener'); + document.removeEventListener(eventName, listener); +} + +/** + * @function broadcasts an event + * the data is stored in the "detail" of the event + * @param eventName {string} the name of the event + * @param data any additional data to be added to event + */ +export function publish(eventName: string, data) { + logDebug('publishing ' + eventName + ' with data ' + JSON.stringify(data)); + const event = new CustomEvent(eventName, { detail: data }); + document.dispatchEvent(event); +} diff --git a/www/js/diary.js b/www/js/diary.js deleted file mode 100644 index 3a150cfff..000000000 --- a/www/js/diary.js +++ /dev/null @@ -1,21 +0,0 @@ -import angular from 'angular'; -import LabelTab from './diary/LabelTab'; - -angular.module('emission.main.diary',['emission.main.diary.services', - 'emission.survey.multilabel.buttons', - 'emission.survey.multilabel.infscrollfilters', - 'emission.survey.enketo.add-note-button', - 'emission.survey.enketo.trip.infscrollfilters', - 'emission.plugin.logger']) - -.config(function($stateProvider) { - $stateProvider - .state('root.main.inf_scroll', { - url: "/inf_scroll", - views: { - 'main-inf-scroll': { - template: "", - }, - } - }) -}); diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 42b173017..938a861f3 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -6,88 +6,144 @@ 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 React, { useEffect, useState, useRef } from 'react'; +import useAppConfig from '../useAppConfig'; +import { useTranslation } from 'react-i18next'; +import { invalidateMaps } from '../components/LeafletView'; +import { DateTime } from 'luxon'; +import LabelListScreen from './list/LabelListScreen'; +import { createStackNavigator } from '@react-navigation/stack'; +import LabelScreenDetails from './details/LabelDetailsScreen'; +import { NavigationContainer } from '@react-navigation/native'; +import { + compositeTrips2TimelineMap, + updateAllUnprocessedInputs, + updateLocalUnprocessedInputs, + unprocessedLabels, + unprocessedNotes, + updateUnprocessedBleScans, + unprocessedBleScans, +} from './timelineHelper'; +import { fillLocationNamesOfTrip, resetNominatimLimiter } from './addressNamesHelper'; +import { getLabelOptions, labelOptionByValue } from '../survey/multilabel/confirmHelper'; +import { displayError, displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; +import { useTheme } from 'react-native-paper'; +import { getPipelineRangeTs } from '../services/commHelper'; +import { + getNotDeletedCandidates, + mapBleScansToTimelineEntries, + mapInputsToTimelineEntries, +} from '../survey/inputMatcher'; +import { configuredFilters as multilabelConfiguredFilters } from '../survey/multilabel/infinite_scroll_filters'; +import { configuredFilters as enketoConfiguredFilters } from '../survey/enketo/infinite_scroll_filters'; +import LabelTabContext, { + LabelTabFilter, + TimelineLabelMap, + TimelineMap, + TimelineNotesMap, +} from './LabelTabContext'; +import { readAllCompositeTrips, readUnprocessedTrips } from './timelineHelper'; +import { LabelOptions, MultilabelKey } from '../types/labelTypes'; +import { CompositeTrip, TimelineEntry, TimestampRange, UserInputEntry } from '../types/diaryTypes'; -let labelPopulateFactory, labelsResultMap, notesResultMap, showPlaces; +let showPlaces; const ONE_DAY = 24 * 60 * 60; // seconds const ONE_WEEK = ONE_DAY * 7; // seconds -export const LabelTabContext = React.createContext(null); const LabelTab = () => { const appConfig = useAppConfig(); const { t } = useTranslation(); const { colors } = useTheme(); - const [surveyOpt, setSurveyOpt] = useState(null); - const [labelOptions, setLabelOptions] = useState(null); - const [filterInputs, setFilterInputs] = useState([]); - const [pipelineRange, setPipelineRange] = useState(null); - const [queriedRange, setQueriedRange] = useState(null); - const [timelineMap, setTimelineMap] = useState(null); - const [displayedEntries, setDisplayedEntries] = useState(null); - const [refreshTime, setRefreshTime] = useState(null); - const [isLoading, setIsLoading] = useState('replace'); - - const $rootScope = getAngularService('$rootScope'); - const $state = getAngularService('$state'); - const $ionicPopup = getAngularService('$ionicPopup'); - const Logger = getAngularService('Logger'); - const Timeline = getAngularService('Timeline'); - const CommHelper = getAngularService('CommHelper'); - const enbs = getAngularService('EnketoNotesButtonService'); + const [labelOptions, setLabelOptions] = useState | null>(null); + const [filterInputs, setFilterInputs] = useState([]); + const [lastFilteredTs, setLastFilteredTs] = useState(null); + const [pipelineRange, setPipelineRange] = useState(null); + const [queriedRange, setQueriedRange] = useState(null); + const [timelineMap, setTimelineMap] = useState(null); + const [timelineLabelMap, setTimelineLabelMap] = useState(null); + const [timelineNotesMap, setTimelineNotesMap] = useState(null); + const [timelineBleMap, setTimelineBleMap] = useState(null); + const [displayedEntries, setDisplayedEntries] = useState(null); + const [refreshTime, setRefreshTime] = useState(null); + const [isLoading, setIsLoading] = useState('replace'); // initialization, once the appConfig is loaded useEffect(() => { - if (!appConfig) return; - const surveyOptKey = appConfig.survey_info['trip-labels']; - const surveyOpt = SurveyOptions[surveyOptKey]; - setSurveyOpt(surveyOpt); - showPlaces = appConfig.survey_info?.buttons?.['place-notes']; - getLabelOptions(appConfig).then((labelOptions) => setLabelOptions(labelOptions)); - labelPopulateFactory = getAngularService(surveyOpt.service); - const tripSurveyName = appConfig.survey_info?.buttons?.['trip-notes']?.surveyName; - const placeSurveyName = appConfig.survey_info?.buttons?.['place-notes']?.surveyName; - enbs.initConfig(tripSurveyName, placeSurveyName); + try { + if (!appConfig) return; + showPlaces = appConfig.survey_info?.buttons?.['place-notes']; + getLabelOptions(appConfig).then((labelOptions) => setLabelOptions(labelOptions)); - // we will show filters if 'additions' are not configured - // https://github.com/e-mission/e-mission-docs/issues/894 - if (appConfig.survey_info?.buttons == undefined) { - // initalize filters - const tripFilterFactory = getAngularService(surveyOpt.filter); - const allFalseFilters = tripFilterFactory.configuredFilters.map((f, i) => ({ - ...f, state: (i == 0 ? true : false) // only the first filter will have state true on init - })); - setFilterInputs(allFalseFilters); + // we will show filters if 'additions' are not configured + // https://github.com/e-mission/e-mission-docs/issues/894 + if (appConfig.survey_info?.buttons == undefined) { + // initalize filters + const tripFilters = + appConfig.survey_info?.['trip-labels'] == 'ENKETO' + ? enketoConfiguredFilters + : multilabelConfiguredFilters; + const allFalseFilters = tripFilters.map((f, i) => ({ + ...f, + state: i == 0 ? true : false, // only the first filter will have state true on init + })); + setFilterInputs(allFalseFilters); + } + loadTimelineEntries(); + } catch (e) { + displayError(e, t('errors.while-initializing-label')); } - loadTimelineEntries(); }, [appConfig, refreshTime]); - // whenever timelineMap is updated, update the displayedEntries - // according to the active filter + // whenever timelineMap is updated, map unprocessed inputs to timeline entries, and + // update the displayedEntries according to the active filter useEffect(() => { - if (!timelineMap) return setDisplayedEntries(null); - const allEntries = Array.from(timelineMap.values()); + try { + if (!timelineMap) return setDisplayedEntries(null); + const allEntries = Array.from(timelineMap.values()); + const [newTimelineLabelMap, newTimelineNotesMap] = mapInputsToTimelineEntries( + allEntries, + appConfig, + ); + + setTimelineLabelMap(newTimelineLabelMap); + setTimelineNotesMap(newTimelineNotesMap); + + if (appConfig.vehicle_identities?.length) { + const newTimelineBleMap = mapBleScansToTimelineEntries(allEntries, appConfig); + setTimelineBleMap(newTimelineBleMap); + } + + applyFilters(timelineMap, newTimelineLabelMap); + } catch (e) { + displayError(e, t('errors.while-updating-timeline')); + } + }, [timelineMap, filterInputs]); + + useEffect(() => { + if (!timelineMap || !timelineLabelMap) return; + applyFilters(timelineMap, timelineLabelMap); + }, [lastFilteredTs]); + + function applyFilters(timelineMap, labelMap: TimelineLabelMap) { + const allEntries: TimelineEntry[] = Array.from(timelineMap.values()); const activeFilter = filterInputs?.find((f) => f.state == true); let entriesToDisplay = allEntries; if (activeFilter) { - const entriesAfterFilter = allEntries.filter( - t => t.justRepopulated || activeFilter?.filter(t) - ); + const cutoffTs = new Date().getTime() / 1000 - 30; // 30s ago, as epoch seconds + const entriesAfterFilter = allEntries.filter((e) => { + // if the entry has a recently recorded user input, it is immune to filtering + const labels = labelMap[e._id.$oid]; + for (let labelValue of Object.values(labels || [])) { + logDebug(`LabelTab filtering: labelValue = ${JSON.stringify(labelValue)}`); + if (labelValue?.metadata?.write_ts > cutoffTs) { + logDebug('LabelTab filtering: entry has recent user input, keeping'); + return true; + } + } + // otherwise, just apply the filter + return activeFilter?.filter(e, labelMap[e._id.$oid]); + }); /* next, filter out any untracked time if the trips that came before and after it are no longer displayed */ entriesToDisplay = entriesAfterFilter.filter((tlEntry) => { @@ -99,19 +155,32 @@ const LabelTab = () => { // if either the trip before or after is displayed, then keep the untracked time return prevTripDisplayed || nextTripDisplayed; }); + logDebug('After filtering, entriesToDisplay = ' + JSON.stringify(entriesToDisplay)); + } else { + logDebug('No active filter, displaying all entries'); } setDisplayedEntries(entriesToDisplay); - }, [timelineMap, filterInputs]); + } async function loadTimelineEntries() { try { - const pipelineRange = await CommHelper.getPipelineRangeTs(); - [labelsResultMap, notesResultMap] = await getAllUnprocessedInputs(pipelineRange, labelPopulateFactory, enbs); - Logger.log("After reading unprocessedInputs, labelsResultMap =" + JSON.stringify(labelsResultMap) - + "; notesResultMap = " + JSON.stringify(notesResultMap)); + const pipelineRange = await getPipelineRangeTs(); + await updateAllUnprocessedInputs(pipelineRange, appConfig); + logDebug(`LabelTab: After updating unprocessedInputs, + unprocessedLabels = ${JSON.stringify(unprocessedLabels)}; + unprocessedNotes = ${JSON.stringify(unprocessedNotes)}`); + if (appConfig.vehicle_identities?.length) { + await updateUnprocessedBleScans({ + start_ts: pipelineRange.start_ts, + end_ts: Date.now() / 1000, + }); + logDebug(`LabelTab: After updating unprocessedBleScans, + unprocessedBleScans = ${JSON.stringify(unprocessedBleScans)}; + `); + } setPipelineRange(pipelineRange); - } catch (error) { - Logger.displayError("Error while loading pipeline range", error); + } catch (e) { + displayError(e, t('errors.while-loading-pipeline-range')); setIsLoading(false); } } @@ -124,98 +193,123 @@ const LabelTab = () => { }, [pipelineRange]); function refresh() { - setIsLoading('replace'); - resetNominatimLimiter(); - setQueriedRange(null); - setTimelineMap(null); - setRefreshTime(new Date()); + try { + logDebug('Refreshing LabelTab'); + setIsLoading('replace'); + resetNominatimLimiter(); + setQueriedRange(null); + setTimelineMap(null); + setRefreshTime(new Date()); + } catch (e) { + displayError(e, t('errors.while-refreshing-label')); + } } - 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; + logDebug('LabelTab: loadAnotherWeek into the ' + when); + if (!pipelineRange?.start_ts || !pipelineRange?.end_ts) + return logWarn('No pipelineRange yet - early return'); + + 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 })); } } - async function loadSpecificWeek(day: string) { + async function loadSpecificWeek(day: Date) { try { + logDebug('LabelTab: loadSpecificWeek for day ' + day); if (!isLoading) setIsLoading('replace'); resetNominatimLimiter(); - const threeDaysBefore = moment(day).subtract(3, 'days').unix(); - const threeDaysAfter = moment(day).add(3, 'days').unix(); + const threeDaysBefore = DateTime.fromJSDate(day).minus({ days: 3 }).toSeconds(); + const threeDaysAfter = DateTime.fromJSDate(day).plus({ days: 3 }).toSeconds(); 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') { + logDebug(`LabelTab: handleFetchedTrips with + mode = ${mode}; + ctList = ${JSON.stringify(ctList)}; + utList = ${JSON.stringify(utList)}`); + const tripsRead = ctList.concat(utList); - 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((trip, index) => fillLocationNamesOfTrip(trip)); const readTimelineMap = compositeTrips2TimelineMap(tripsRead, showPlaces); + logDebug(`LabelTab: after composite trips converted, + readTimelineMap = ${[...readTimelineMap.entries()]}`); if (mode == 'append') { - setTimelineMap(new Map([...timelineMap, ...readTimelineMap])); + setTimelineMap(new Map([...(timelineMap || []), ...readTimelineMap])); } else if (mode == 'prepend') { - setTimelineMap(new Map([...readTimelineMap, ...timelineMap])); + setTimelineMap(new Map([...readTimelineMap, ...(timelineMap || [])])); } else if (mode == 'replace') { setTimelineMap(readTimelineMap); } else { - return console.error("Unknown insertion mode " + mode); + return displayErrorMsg('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"); - return; - } - - const readCompositePromise = Timeline.readAllCompositeTrips(startTs, endTs); + if (!pipelineRange?.start_ts || !pipelineRange?.end_ts) + return logWarn('No pipelineRange yet - early return'); + logDebug('LabelTab: fetchTripsInRange from ' + startTs + ' to ' + endTs); + const readCompositePromise = readAllCompositeTrips(startTs, endTs); 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') - ); - readUnprocessedPromise = Timeline.readUnprocessedTrips(pipelineRange.end_ts, nowTs, lastProcessedTrip); + let lastProcessedTrip: CompositeTrip | undefined; + if (timelineMap) { + lastProcessedTrip = [...timelineMap?.values()] + .reverse() + .find((trip) => trip.origin_key.includes('trip')) as CompositeTrip; + } + readUnprocessedPromise = readUnprocessedTrips(pipelineRange.end_ts, nowTs, lastProcessedTrip); } else { readUnprocessedPromise = Promise.resolve([]); } const results = await Promise.all([readCompositePromise, readUnprocessedPromise]); + logDebug(`LabelTab: readCompositePromise resolved as: ${JSON.stringify(results[0])}; + readUnprocessedPromise resolved as: ${JSON.stringify(results[1])}`); return results; - }; + } useEffect(() => { if (!displayedEntries) return; @@ -223,34 +317,71 @@ const LabelTab = () => { setIsLoading(false); }, [displayedEntries]); - 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); - const repopTime = new Date().getTime(); - const newEntry = {...timelineMap.get(oid), justRepopulated: repopTime}; - labelPopulateFactory.populateInputsAndInferences(newEntry, newLabels); - enbs.populateInputsAndInferences(newEntry, newNotes); - const newTimelineMap = new Map(timelineMap).set(oid, newEntry); - setTimelineMap(newTimelineMap); + const userInputFor = (tlEntry: TimelineEntry) => + timelineLabelMap?.[tlEntry._id.$oid] || undefined; + const notesFor = (tlEntry: TimelineEntry) => timelineNotesMap?.[tlEntry._id.$oid] || undefined; + + /** + * @param tlEntry The trip or place object to get the label for + * @param labelType The type of label to get (e.g. MODE, PURPOSE, etc.) + * @returns the label option object for the given label type, or undefined if there is no label + */ + const labelFor = (tlEntry: TimelineEntry, labelType: MultilabelKey) => { + const chosenLabel = userInputFor(tlEntry)?.[labelType]?.data.label; + return chosenLabel ? labelOptionByValue(chosenLabel, labelType) : undefined; + }; + + /** + * @param tlEntry The trip or place object to get the confirmed mode for + * @returns Confirmed mode, which could be a vehicle identity as determined by Bluetooth scans, + * or the label option from a user-given 'MODE' label, or undefined if neither exists. + */ + const confirmedModeFor = (tlEntry: TimelineEntry) => + timelineBleMap?.[tlEntry._id.$oid] || labelFor(tlEntry, 'MODE'); - // after 30 seconds, remove the justRepopulated flag unless it was repopulated again since then - /* ref is needed to avoid stale closure: - 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)}; - if (entry.justRepopulated != repopTime) - return console.log("Entry " + oid + " was repopulated again, skipping"); - const newTimelineMap = new Map(timelineMapRef.current).set(oid, {...entry, justRepopulated: false}); - setTimelineMap(newTimelineMap); - }, 30000); + function addUserInputToEntry(oid: string, userInput: any, inputType: 'label' | 'note') { + const tlEntry = timelineMap?.get(oid); + if (!pipelineRange || !tlEntry) + return displayErrorMsg('Item with oid: ' + oid + ' not found in timeline'); + const nowTs = new Date().getTime() / 1000; // epoch seconds + if (inputType == 'label') { + const newLabels = {}; + for (const [inputType, labelValue] of Object.entries(userInput)) { + newLabels[inputType] = { data: labelValue, metadata: nowTs }; + } + logDebug('LabelTab: newLabels = ' + JSON.stringify(newLabels)); + const newTimelineLabelMap: TimelineLabelMap = { + ...timelineLabelMap, + [oid]: { + ...timelineLabelMap?.[oid], + ...newLabels, + }, + }; + setTimelineLabelMap(newTimelineLabelMap); + setTimeout(() => setLastFilteredTs(new Date().getTime() / 1000), 30000); // wait 30s before reapplying filters + } else if (inputType == 'note') { + const notesForEntry = timelineNotesMap?.[oid] || []; + const newAddition = { data: userInput, metadata: { write_ts: nowTs } }; + notesForEntry.push(newAddition as UserInputEntry); + const newTimelineNotesMap: TimelineNotesMap = { + ...timelineNotesMap, + [oid]: getNotDeletedCandidates(notesForEntry), + }; + setTimelineNotesMap(newTimelineNotesMap); + } + /* We can update unprocessed inputs in the background, without blocking the completion + of this function. That is why this is not 'await'ed */ + updateLocalUnprocessedInputs(pipelineRange, appConfig); } const contextVals = { - surveyOpt, labelOptions, timelineMap, + userInputFor, + labelFor, + notesFor, + confirmedModeFor, + addUserInputToEntry, displayedEntries, filterInputs, setFilterInputs, @@ -260,25 +391,27 @@ const LabelTab = () => { loadAnotherWeek, loadSpecificWeek, refresh, - repopulateTimelineEntry, - } + }; const Tab = createStackNavigator(); return ( - + - + options={{ detachPreviousScreen: false }} + /> ); -} +}; export default LabelTab; diff --git a/www/js/diary/LabelTabContext.ts b/www/js/diary/LabelTabContext.ts new file mode 100644 index 000000000..791cb4cd5 --- /dev/null +++ b/www/js/diary/LabelTabContext.ts @@ -0,0 +1,51 @@ +import { createContext } from 'react'; +import { TimelineEntry, TimestampRange, UserInputEntry } from '../types/diaryTypes'; +import { LabelOption, LabelOptions, MultilabelKey } from '../types/labelTypes'; +import { EnketoUserInputEntry } from '../survey/enketo/enketoHelper'; +import { VehicleIdentity } from '../types/appConfigTypes'; + +export type UserInputMap = { + /* if the key here is 'SURVEY', we are in the ENKETO configuration, meaning the user input + value will have the raw 'xmlResponse' string */ + SURVEY?: EnketoUserInputEntry; +} & { + /* all other keys, (e.g. 'MODE', 'PURPOSE') are from the MULTILABEL configuration + and will have the 'label' string but no 'xmlResponse' string */ + [k in MultilabelKey]?: UserInputEntry; +}; + +export type TimelineMap = Map; // Todo: update to reflect unpacked trips (origin_Key, etc) +export type TimelineLabelMap = { + [k: string]: UserInputMap; +}; +export type TimelineNotesMap = { + [k: string]: UserInputEntry[]; +}; + +export type LabelTabFilter = { + key: string; + text: string; + filter: (trip: TimelineEntry, userInputForTrip: UserInputMap) => boolean; + state?: boolean; +}; + +type ContextProps = { + labelOptions: LabelOptions | null; + timelineMap: TimelineMap | null; + userInputFor: (tlEntry: TimelineEntry) => UserInputMap | undefined; + notesFor: (tlEntry: TimelineEntry) => UserInputEntry[] | undefined; + labelFor: (tlEntry: TimelineEntry, labelType: MultilabelKey) => LabelOption | undefined; + confirmedModeFor: (tlEntry: TimelineEntry) => VehicleIdentity | LabelOption | undefined; + addUserInputToEntry: (oid: string, userInput: any, inputType: 'label' | 'note') => void; + displayedEntries: TimelineEntry[] | null; + filterInputs: LabelTabFilter[]; + setFilterInputs: (filters: LabelTabFilter[]) => void; + queriedRange: TimestampRange | null; + pipelineRange: TimestampRange | null; + isLoading: string | false; + loadAnotherWeek: (when: 'past' | 'future') => void; + loadSpecificWeek: (d: Date) => void; + refresh: () => void; +}; + +export default createContext({} as ContextProps); diff --git a/www/js/diary/addressNamesHelper.ts b/www/js/diary/addressNamesHelper.ts index e7f198fbe..30740ad19 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,8 +39,7 @@ export const LocalStorageObserver = createObserver(); export const { subscribe, publish } = LocalStorageObserver; export function useLocalStorage(key: string, initialValue: T) { - - const [storedValue, setStoredValue] = useState(() => { + const [storedValue, setStoredValue] = useState(() => { try { const item = window.localStorage.getItem(key); return item || initialValue; @@ -56,32 +53,28 @@ export function useLocalStorage(key: string, initialValue: T) { keyRef.current = key; // force state update const storedValue = window.localStorage.getItem(key); - setStoredValue(storedValue); + if (storedValue) setStoredValue(storedValue); } LocalStorageObserver.subscribe(key, setStoredValue); - const setValue = (value: T) => { + function 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') { window.localStorage.setItem(key, JSON.stringify(valueToStore)); } } catch (error) { - console.error(error); + displayError(error); } - }; + } return [storedValue, setValue]; } - - - -import Bottleneck from "bottleneck"; -import { getAngularService } from "../angular-react-helper"; +import Bottleneck from 'bottleneck'; +import { displayError, logDebug } from '../plugin/logger'; let nominatimLimiter = new Bottleneck({ maxConcurrent: 2, minTime: 500 }); export const resetNominatimLimiter = () => { @@ -93,53 +86,60 @@ 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 '...'; } let nominatimError: Error; -let Logger; // fetches nominatim data for a given location and stores it using the coordinates as the key // if the address name is already cached, it skips the fetch async function fetchNominatimLocName(loc_geojson) { - Logger = Logger || getAngularService('Logger'); const coordsStr = loc_geojson.coordinates.toString(); const cachedResponse = localStorage.getItem(coordsStr); if (cachedResponse) { - console.log('fetchNominatimLocName: found cached response for ', coordsStr, cachedResponse, 'skipping fetch'); + logDebug(`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]; + logDebug('Getting location name for ' + JSON.stringify(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)}`); + logDebug(`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); + displayError(error, 'while reading address data'); } } -}; +} // 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..73a5868af 100644 --- a/www/js/diary/cards/DiaryCard.tsx +++ b/www/js/diary/cards/DiaryCard.tsx @@ -7,35 +7,57 @@ (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 ( - - - + + + {displayStartTime && ( + + )} {children} - - + + {displayEndTime && ( + + )} ); -} +}; // common styles, used for DiaryCard export const cardStyles = StyleSheet.create({ @@ -57,4 +79,10 @@ export const cardStyles = StyleSheet.create({ width: '100%', paddingBottom: 10, }, + notesButton: { + paddingHorizontal: 8, + paddingVertical: 8, + minWidth: 150, + margin: 'auto', + }, }); diff --git a/www/js/diary/cards/ModesIndicator.tsx b/www/js/diary/cards/ModesIndicator.tsx index 5211f7ed4..4e68da4de 100644 --- a/www/js/diary/cards/ModesIndicator.tsx +++ b/www/js/diary/cards/ModesIndicator.tsx @@ -1,62 +1,87 @@ import React, { useContext } from 'react'; import { View, StyleSheet } from 'react-native'; -import color from "color"; -import { LabelTabContext } from '../LabelTab'; +import color from 'color'; +import LabelTabContext from '../LabelTabContext'; import { logDebug } from '../../plugin/logger'; -import { getBaseModeOfLabeledTrip } from '../diaryHelper'; -import { Icon } from '../../components/Icon'; -import { Text, useTheme } from 'react-native-paper'; +import { getBaseModeByKey, getBaseModeByValue } from '../diaryHelper'; +import { Text, Icon, 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 { labelOptions, labelFor, confirmedModeFor } = 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) { - const baseMode = getBaseModeOfLabeledTrip(trip, labelOptions); + const confirmedModeForTrip = confirmedModeFor(trip); + if (labelOptions && confirmedModeForTrip?.value) { + const baseMode = getBaseModeByKey(confirmedModeForTrip.baseMode); indicatorBorderColor = baseMode.color; logDebug(`TripCard: got baseMode = ${JSON.stringify(baseMode)}`); modeViews = ( - - - {trip.userInput.MODE.text} + + + {confirmedModeForTrip.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({ + indicatorWrapper: { + position: 'absolute', + width: '100%', + paddingHorizontal: 5, + }, modesIndicator: { marginVertical: 5, marginHorizontal: 'auto', diff --git a/www/js/diary/cards/PlaceCard.tsx b/www/js/diary/cards/PlaceCard.tsx index cd1d9c10e..6936146e6 100644 --- a/www/js/diary/cards/PlaceCard.tsx +++ b/www/js/diary/cards/PlaceCard.tsx @@ -6,51 +6,65 @@ PlaceCards use the blueish 'place' theme flavor. */ -import React from "react"; +import React, { useContext } 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'; +import LabelTabContext from '../LabelTabContext'; +import { ConfirmedPlace } from '../../types/diaryTypes'; +import { EnketoUserInputEntry } from '../../survey/enketo/enketoHelper'; -type Props = { place: {[key: string]: any} }; +type Props = { place: ConfirmedPlace }; const PlaceCard = ({ place }: Props) => { - const appConfig = useAppConfig(); + const { notesFor } = useContext(LabelTabContext); 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 */} + + - - - + {notesFor(place)?.length && ( + + + + )} ); }; @@ -60,11 +74,6 @@ const s = StyleSheet.create({ marginTop: 12, marginBottom: 6, }, - notesButton: { - paddingHorizontal: 8, - minWidth: 150, - margin: 'auto', - }, locationText: { fontSize: 14, lineHeight: 14, 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..f0f8a1284 100644 --- a/www/js/diary/cards/TripCard.tsx +++ b/www/js/diary/cards/TripCard.tsx @@ -4,98 +4,146 @@ 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"; - -type Props = { trip: {[key: string]: any}}; -const TripCard = ({ trip }: Props) => { +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 '../LabelTabContext'; +import useDerivedProperties from '../useDerivedProperties'; +import StartEndLocations from '../components/StartEndLocations'; +import ModesIndicator from './ModesIndicator'; +import { useGeojsonForTrip } from '../timelineHelper'; +import { CompositeTrip } from '../../types/diaryTypes'; +import { EnketoUserInputEntry } from '../../survey/enketo/enketoHelper'; +type Props = { trip: CompositeTrip; isFirstInList?: boolean }; +const TripCard = ({ trip, isFirstInList }: 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); + const { labelOptions, confirmedModeFor, notesFor } = useContext(LabelTabContext); + const tripGeojson = + trip && labelOptions && useGeojsonForTrip(trip, confirmedModeFor(trip)?.baseMode); const isDraft = trip.key.includes('UNPROCESSED'); const flavoredTheme = getTheme(isDraft ? 'draft' : undefined); 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 }; + const mapOpts = { attributionControl: isFirstInList, zoomControl: false, dragging: false }; const showAddNoteButton = appConfig?.survey_info?.buttons?.['trip-notes']; 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 */} + {appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL' && ( + + )} + {appConfig?.survey_info?.['trip-labels'] == 'ENKETO' && ( + + )} - {/* left panel */} - + {/* left panel */} + {tripGeojson && ( + + style={[{ minHeight: windowWidth / 2 }, mapStyle]} + /> + )} - {showAddNoteButton && - - + {showAddNoteButton && ( + + - } + )} - {trip.additionsList?.length != 0 && + {notesFor(trip)?.length && ( - + - } + )} ); }; @@ -113,12 +161,6 @@ const s = StyleSheet.create({ borderTopLeftRadius: 15, borderBottomRightRadius: 15, }, - notesButton: { - paddingHorizontal: 8, - paddingVertical: 8, - minWidth: 150, - margin: 'auto', - }, rightPanel: { flex: 1, paddingHorizontal: 5, 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..ccc5c8ac6 100644 --- a/www/js/diary/components/StartEndLocations.tsx +++ b/www/js/diary/components/StartEndLocations.tsx @@ -1,70 +1,72 @@ import React from 'react'; -import { View, ViewProps } from 'react-native'; -import { Icon } from '../../components/Icon'; -import { Text, Divider, useTheme } from 'react-native-paper'; +import { View } from 'react-native'; +import { Text, Divider, Icon, 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 const, + 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 const, +}; export default StartEndLocations; diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx index ffed9a300..9627ebcaa 100644 --- a/www/js/diary/details/LabelDetailsScreen.tsx +++ b/www/js/diary/details/LabelDetailsScreen.tsx @@ -2,84 +2,129 @@ 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 '../LabelTabContext'; +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 useAppConfig from '../../useAppConfig'; +import { CompositeTrip } from '../../types/diaryTypes'; +import NavBar from '../../components/NavBar'; const LabelScreenDetails = ({ route, navigation }) => { - - const { surveyOpt, timelineMap, labelOptions } = useContext(LabelTabContext); + const { timelineMap, labelOptions, confirmedModeFor } = useContext(LabelTabContext); const { t } = useTranslation(); const { height: windowHeight } = useWindowDimensions(); + const appConfig = useAppConfig(); const { tripId, flavoredTheme } = route.params; - const trip = timelineMap.get(tripId); + const trip = timelineMap?.get(tripId) as CompositeTrip; 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<'confirmed' | 'detected'>(() => + // if trip has a labeled mode, initial state shows that; otherwise, show detected modes + trip && confirmedModeFor(trip)?.value ? 'confirmed' : 'detected', + ); + const tripGeojson = + trip && + labelOptions && + useGeojsonForTrip( + trip, + modesShown == 'confirmed' ? confirmedModeFor(trip)?.baseMode : undefined, + ); + 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' - && } + {appConfig?.survey_info?.['trip-labels'] == 'MULTILABEL' && ( + + )} + {appConfig?.survey_info?.['trip-labels'] == 'ENKETO' && ( + + )} - - {/* Full-size Leaflet map, with zoom controls */} - + {tripGeojson && ( + // 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 as CompositeTrip)?.sections?.length > 1 && ( - } + )} {/* TODO: show speed graph here */} @@ -87,13 +132,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..33c00fcfc 100644 --- a/www/js/diary/details/OverallTripDescriptives.tsx +++ b/www/js/diary/details/OverallTripDescriptives.tsx @@ -1,42 +1,44 @@ import React from 'react'; import { View } from 'react-native'; -import { Text } from 'react-native-paper' +import { Text, Icon } 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..9e117021c 100644 --- a/www/js/diary/details/TripSectionsDescriptives.tsx +++ b/www/js/diary/details/TripSectionsDescriptives.tsx @@ -1,65 +1,88 @@ import React, { useContext } from 'react'; -import { View } from 'react-native'; -import { Text, useTheme } from 'react-native-paper' -import { Icon } from '../../components/Icon'; +import { View, StyleSheet } from 'react-native'; +import { Icon, Text, useTheme } from 'react-native-paper'; import useDerivedProperties from '../useDerivedProperties'; -import { getBaseModeByKey, getBaseModeOfLabeledTrip } from '../diaryHelper'; -import { LabelTabContext } from '../LabelTab'; +import { getBaseModeByKey, getBaseModeByValue } from '../diaryHelper'; +import LabelTabContext from '../LabelTabContext'; -const TripSectionsDescriptives = ({ trip, showLabeledMode=false }) => { - - const { labelOptions } = useContext(LabelTabContext); - const { displayStartTime, displayTime, formattedDistance, - distanceSuffix, formattedSectionProperties } = useDerivedProperties(trip); +const TripSectionsDescriptives = ({ trip, showConfirmedMode = false }) => { + const { labelOptions, labelFor, confirmedModeFor } = useContext(LabelTabContext); + const { + displayStartTime, + displayTime, + formattedDistance, + distanceSuffix, + formattedSectionProperties, + } = useDerivedProperties(trip); const { colors } = useTheme(); + const confirmedModeForTrip = confirmedModeFor(trip); 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 ((showConfirmedMode && confirmedModeForTrip) || !trip.sections?.length) { let baseMode; - if (showLabeledMode && trip?.userInput?.MODE) { - baseMode = getBaseModeOfLabeledTrip(trip, labelOptions); + if (showConfirmedMode && labelOptions && confirmedModeForTrip) { + baseMode = getBaseModeByKey(confirmedModeForTrip.baseMode); } 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, + distanceSuffix, + color: baseMode.color, + icon: baseMode.icon, + }, + ]; } return ( {sections.map((section, i) => ( - + - {section.duration} - {section.startTime} + {section.duration} + {section.startTime} - - {`${section.distance} ${distanceSuffix}`} - + {`${section.distance} ${distanceSuffix}`} - - - {section.text && - - {section.text} + + + + + {showConfirmedMode && confirmedModeForTrip && ( + + {confirmedModeForTrip.text} - } + )} ))} ); -} +}; + +const s = StyleSheet.create({ + modeIconContainer: (bgColor) => ({ + backgroundColor: bgColor, + height: 32, + width: 32, + borderRadius: 16, + justifyContent: 'center', + alignItems: 'center', + }), +}); export default TripSectionsDescriptives; diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index 0b834a485..f02797fff 100644 --- a/www/js/diary/diaryHelper.ts +++ b/www/js/diary/diaryHelper.ts @@ -1,85 +1,95 @@ // 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 i18next from 'i18next'; +import { DateTime } from 'luxon'; +import { CompositeTrip } from '../types/diaryTypes'; +import { LabelOptions } from '../types/labelTypes'; +import { LocalDt } from '../types/serverData'; +import humanizeDuration from 'humanize-duration'; +import { AppConfig } from '../types/appConfigTypes'; +import { ImperialConfig } from '../config/useImperialConfig'; 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} = { +export 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; +export 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}`) { - let key = ('' + motionName).toUpperCase(); - key = key.split(".").pop(); // if "MotionTypes.WALKING", then just take "WALKING" - return BaseModes[key] || BaseModes.UNKNOWN; +export function getBaseModeByKey( + motionName: BaseModeKey | MotionTypeKey | `MotionTypes.${MotionTypeKey}`, +) { + const key = ('' + motionName).toUpperCase(); + const pop = key.split('.').pop(); // if "MotionTypes.WALKING", then just take "WALKING" + return (pop && BaseModes[pop]) || 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"); +export function getBaseModeByValue(value: string, labelOptions: LabelOptions) { + const modeOption = labelOptions?.MODE?.find((opt) => opt.value == value); + 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"); -} - -export function getBaseModeByText(text, labelOptions: LabelOptions) { - const modeOption = labelOptions?.MODE?.find(opt => opt.text == text); - return getBaseModeByKey(modeOption?.baseMode || "OTHER"); +export function getBaseModeByText(text: string, labelOptions: LabelOptions) { + const modeOption = labelOptions?.MODE?.find((opt) => opt.text == text); + return getBaseModeByKey(modeOption?.baseMode || 'OTHER'); } /** @@ -88,9 +98,12 @@ export function getBaseModeByText(text, labelOptions: LabelOptions) { * @returns true if the start and end timestamps fall on different days * @example isMultiDay("2023-07-13T00:00:00-07:00", "2023-07-14T00:00:00-07:00") => true */ -export function isMultiDay(beginFmtTime: string, endFmtTime: string) { +export function isMultiDay(beginFmtTime?: string, endFmtTime?: string) { if (!beginFmtTime || !endFmtTime) return false; - return moment.parseZone(beginFmtTime).format('YYYYMMDD') != moment.parseZone(endFmtTime).format('YYYYMMDD'); + return ( + DateTime.fromISO(beginFmtTime, { setZone: true }).toFormat('YYYYMMDD') != + DateTime.fromISO(endFmtTime, { setZone: true }).toFormat('YYYYMMDD') + ); } /** @@ -99,17 +112,21 @@ export function isMultiDay(beginFmtTime: string, endFmtTime: string) { * @returns A formatted range if both params are defined, one formatted date if only one is defined * @example getFormattedDate("2023-07-14T00:00:00-07:00") => "Fri, Jul 14, 2023" */ -export function getFormattedDate(beginFmtTime: string, endFmtTime?: string) { +export function getFormattedDate(beginFmtTime?: string, endFmtTime?: string) { if (!beginFmtTime && !endFmtTime) return; if (isMultiDay(beginFmtTime, endFmtTime)) { return `${getFormattedDate(beginFmtTime)} - ${getFormattedDate(endFmtTime)}`; } // only one day given, or both are the same day - const t = moment.parseZone(beginFmtTime || endFmtTime); - // We use ddd LL to get Wed, May 3, 2023 or equivalent - // LL only has the date, month and year - // LLLL has the day of the week, but also the time - return t.format('ddd LL'); + const t = DateTime.fromISO(beginFmtTime || endFmtTime || '', { setZone: true }); + // We use toLocale to get Wed May 3, 2023 or equivalent, + const tConversion = t.toLocaleString({ + weekday: 'short', + month: 'long', + day: '2-digit', + year: 'numeric', + }); + return tConversion; } /** @@ -118,13 +135,13 @@ export function getFormattedDate(beginFmtTime: string, endFmtTime?: string) { * @returns A formatted range if both params are defined, one formatted date if only one is defined * @example getFormattedDate("2023-07-14T00:00:00-07:00") => "Fri, Jul 14" */ -export function getFormattedDateAbbr(beginFmtTime: string, endFmtTime?: string) { +export function getFormattedDateAbbr(beginFmtTime?: string, endFmtTime?: string) { if (!beginFmtTime && !endFmtTime) return; if (isMultiDay(beginFmtTime, endFmtTime)) { return `${getFormattedDateAbbr(beginFmtTime)} - ${getFormattedDateAbbr(endFmtTime)}`; } // only one day given, or both are the same day - const dt = DateTime.fromISO(beginFmtTime || endFmtTime, { setZone: true }); + const dt = DateTime.fromISO(beginFmtTime || endFmtTime || '', { setZone: true }); return dt.toLocaleString({ weekday: 'short', month: 'short', day: 'numeric' }); } @@ -135,57 +152,51 @@ export function getFormattedDateAbbr(beginFmtTime: string, endFmtTime?: string) */ export function getFormattedTimeRange(beginFmtTime: string, endFmtTime: string) { if (!beginFmtTime || !endFmtTime) return; - 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; - -export function getDetectedModes(trip) { - if (!trip.sections?.length) return []; - - // sum up the distances for each mode, as well as the total distance - let totalDist = 0; - const dists: Record = {}; - trip.sections.forEach((section) => { - const filteredMode = filterRunning(section.sensed_mode_str); - dists[filteredMode] = (dists[filteredMode] || 0) + section.distance; - totalDist += section.distance; + const beginTime = DateTime.fromISO(beginFmtTime, { setZone: true }); + const endTime = DateTime.fromISO(endFmtTime, { setZone: true }); + const range = endTime.diff(beginTime, ['hours', 'minutes']); + return humanizeDuration(range.as('milliseconds'), { + language: i18next.resolvedLanguage, + largest: 1, + round: true, }); +} - // sort modes by the distance traveled (descending) - 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, +/** + * @param trip A composite trip object + * @returns An array of objects containing the mode key, icon, color, and percentage for each mode + * detected in the trip + */ +export function getDetectedModes(trip: CompositeTrip) { + const sectionSummary = trip?.inferred_section_summary || trip?.cleaned_section_summary; + if (!sectionSummary?.distance) return []; + + return Object.entries(sectionSummary.distance) + .sort(([modeA, distA], [modeB, distB]) => distB - distA) // sort by distance (highest first) + .map(([mode, dist]: [MotionTypeKey, number]) => ({ + mode, icon: getBaseModeByKey(mode)?.icon, color: getBaseModeByKey(mode)?.color || 'black', - pct: Math.round(fract * 100) || '<1' // if rounds to 0%, show <1% - }; - }); - - return sectionPcts; + pct: Math.round((dist / trip.distance) * 100) || '<1', // if rounds to 0%, show <1% + })); } -export function getFormattedSectionProperties(trip, ImperialConfig) { +export function getFormattedSectionProperties(trip: CompositeTrip, imperialConfig: ImperialConfig) { return trip.sections?.map((s) => ({ startTime: getLocalTimeString(s.start_local_dt), duration: getFormattedTimeRange(s.start_fmt_time, s.end_fmt_time), - distance: ImperialConfig.getFormattedDistance(s.distance), - distanceSuffix: ImperialConfig.distanceSuffix, + 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', })); } -export function getLocalTimeString(dt) { +export function getLocalTimeString(dt?: LocalDt) { 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 dateTime = DateTime.fromObject({ + hour: dt.hour, + minute: dt.minute, + }); + return dateTime.toLocaleString(DateTime.TIME_SIMPLE); } diff --git a/www/js/diary/diaryTypes.ts b/www/js/diary/diaryTypes.ts deleted file mode 100644 index bcaeb83ae..000000000 --- a/www/js/diary/diaryTypes.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* These type definitions are a work in progress. The goal is to have a single source of truth for - the types of the trip / place / untracked objects and all properties they contain. - Since we are using TypeScript now, we should strive to enforce type safety and also benefit from - IntelliSense and other IDE features. */ - -// Since it is WIP, these types are not used anywhere yet. - -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 -} - -/* 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 }[], -} - -/* 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, -} diff --git a/www/js/diary/list/DateSelect.tsx b/www/js/diary/list/DateSelect.tsx index 1c28cdc2c..02b8d1ca1 100644 --- a/www/js/diary/list/DateSelect.tsx +++ b/www/js/diary/list/DateSelect.tsx @@ -6,24 +6,23 @@ 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 { DateTime } from 'luxon'; +import LabelTabContext from '../LabelTabContext'; +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/NavBar'; const DateSelect = ({ tsRange, loadSpecificWeekFn }) => { - const { pipelineRange } = useContext(LabelTabContext); const { t } = useTranslation(); const { colors } = useTheme(); const [open, setOpen] = React.useState(false); const [dateRange, setDateRange] = useState([null, null]); - const [selDate, setSelDate] = useState(null); + const [selDate, setSelDate] = useState(new Date()); const minMaxDates = useMemo(() => { if (!pipelineRange) return { startDate: new Date(), endDate: new Date() }; return { @@ -33,13 +32,15 @@ const DateSelect = ({ tsRange, loadSpecificWeekFn }) => { }, [pipelineRange]); useEffect(() => { - if (!tsRange.oldestTs) return; + if (!pipelineRange || !tsRange.oldestTs) return; const displayStartTs = Math.max(tsRange.oldestTs, pipelineRange.start_ts); - const displayStartDate = moment.unix(displayStartTs).format('L'); + const displayStartDate = DateTime.fromSeconds(displayStartTs).toLocaleString( + DateTime.DATE_SHORT, + ); let displayEndDate; if (tsRange.latestTs < pipelineRange.end_ts) { - displayEndDate = moment.unix(tsRange.latestTs).format('L'); + displayEndDate = DateTime.fromSeconds(tsRange.latestTs).toLocaleString(DateTime.DATE_SHORT); } setDateRange([displayStartDate, displayEndDate]); @@ -57,36 +58,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..039d76be0 100644 --- a/www/js/diary/list/FilterSelect.tsx +++ b/www/js/diary/list/FilterSelect.tsx @@ -7,36 +7,43 @@ 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"; - -const FilterSelect = ({ filters, setFilters, numListDisplayed, numListTotal }) => { +import React, { useState, useMemo } from 'react'; +import { Modal } from 'react-native'; +import { useTranslation } from 'react-i18next'; +import { RadioButton, Text, Dialog } from 'react-native-paper'; +import { NavBarButton } from '../../components/NavBar'; +import { LabelTabFilter } from '../LabelTabContext'; +type Props = { + filters: LabelTabFilter[]; + setFilters: (filters: LabelTabFilter[]) => void; + numListDisplayed?: number; + numListTotal?: number; +}; +const FilterSelect = ({ filters, setFilters, numListDisplayed, numListTotal }: Props) => { 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 +51,30 @@ 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..7dfcb676d 100644 --- a/www/js/diary/list/LabelListScreen.tsx +++ b/www/js/diary/list/LabelListScreen.tsx @@ -1,38 +1,59 @@ -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 '../LabelTabContext'; +import NavBar from '../../components/NavBar'; 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 05fa2ecd1..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..79faffbdf 100644 --- a/www/js/diary/list/TimelineScrollList.tsx +++ b/www/js/diary/list/TimelineScrollList.tsx @@ -1,61 +1,67 @@ import React from 'react'; -import { FlashList } from '@shopify/flash-list'; import TripCard from '../cards/TripCard'; import PlaceCard from '../cards/PlaceCard'; import UntrackedTimeCard from '../cards/UntrackedTimeCard'; -import { View } from 'react-native'; -import { ActivityIndicator, Banner, Text } from 'react-native-paper'; +import { View, FlatList } from 'react-native'; +import { ActivityIndicator, Banner, Icon, Text } from 'react-native-paper'; import LoadMoreButton from './LoadMoreButton'; import { useTranslation } from 'react-i18next'; -import { Icon } from '../../components/Icon'; -const renderCard = ({ item: listEntry }) => { +function renderCard({ item: listEntry, index }) { 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 ; + } else { + throw new Error(`Unknown listEntry type: ${JSON.stringify(listEntry)}`); } -}; +} -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[] | null; + queriedRange: any; + pipelineRange: any; + loadMoreFn: (direction: string) => void; + isLoading: boolean | string; +}; +const TimelineScrollList = ({ + listEntries, + queriedRange, + pipelineRange, + loadMoreFn, + isLoading, +}: Props) => { const { t } = useTranslation(); + const listRef = React.useRef(null); // 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 +70,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,20 +79,45 @@ const TimelineScrollList = ({ listEntries, queriedRange, pipelineRange, loadMore } else if (listEntries) { /* Condition: we've successfully loaded and set `listEntries`, so show the list */ return ( - item._id.$oid} /* TODO: We can capture onScroll events like this, so we should be able to automatically load more trips when the user is approaching the bottom or top of the list. This might be a nicer experience than the current header and footer buttons. */ // onScroll={e => console.debug(e.nativeEvent.contentOffset.y)} - ListHeaderComponent={isLoading == 'append' ? smallSpinner : (!reachedPipelineEnd && header)} + ListHeaderComponent={ + isLoading == 'append' ? smallSpinner : !reachedPipelineEnd ? header : null + } ListFooterComponent={isLoading == 'prepend' ? smallSpinner : footer} - ItemSeparatorComponent={separator} /> + ItemSeparatorComponent={separator} + /* use column-reverse so that the list is 'inverted', meaning it should start + scrolling from the bottom, and the bottom-most item should be first in the DOM tree + This method is used instead of the `inverted` property of FlatList, because `inverted` + uses CSS transforms to flip the entire list and then flip each list item back, which + is a performance hit and causes scrolling to be choppy, especially on old iPhones. */ + style={{ flexDirection: 'column-reverse' }} + contentContainerStyle={{ flexDirection: 'column-reverse' }} + /* Workaround for iOS Safari bug where a 'column-reverse' element containing scroll content + shows up blank until it's scrolled or its layout changes. + Adding a temporary 1px margin-right, and then removing it on the next event loop, + is the least intrusive way I've found to trigger a layout change. + It basically just jiggles the element so it doesn't blank out. */ + onContentSizeChange={() => { + const list = document.getElementById('timelineScrollList'); + list?.style.setProperty('margin-right', '1px'); + setTimeout(() => { + list?.style.setProperty('margin-right', '0'); + }); + }} + /> ); + } else { + return <>; } -} +}; export default TimelineScrollList; diff --git a/www/js/diary/services.js b/www/js/diary/services.js deleted file mode 100644 index c9dfd1bbf..000000000 --- a/www/js/diary/services.js +++ /dev/null @@ -1,387 +0,0 @@ -'use strict'; - -import angular from 'angular'; -import { getBaseModeByKey, getBaseModeOfLabeledTrip } from './diaryHelper'; -import { SurveyOptions } from '../survey/survey'; -import { getConfig } from '../config/dynamicConfig'; - -angular.module('emission.main.diary.services', ['emission.plugin.logger', - 'emission.services']) -.factory('Timeline', function(CommHelper, $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') - }); - const readPromises = [ - CommHelper.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); - return { - ...unpackedCt, - start_confirmed_place: unpack(unpackedCt.start_confirmed_place), - 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); - $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) { - var inTrip = false; - var tripList = [] - var currStartTransitionIndex = -1; - var currEndTransitionIndex = -1; - var processedUntil = 0; - - 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; - } - } 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; - } - } - } - return tripList; - } - - 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; - } - // Logger.log("Returning false"); - return false; - } - - 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; - } - // 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 - */ - - 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() - }; - } - - 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] - }, - 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) { - 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 filteredLocationList = sortedLocationList.filter(retainInRange); - - // Fix for https://github.com/e-mission/e-mission-docs/issues/417 - if (filteredLocationList.length == 0) { - return undefined; - } - - var tripStartPoint = filteredLocationList[0]; - 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 - // 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)); - } - - const tripProps = points2TripProps(filteredLocationList); - - return { - ...tripProps, - start_loc: { - type: "Point", - coordinates: [tripStartPoint.data.longitude, tripStartPoint.data.latitude] - }, - end_loc: { - type: "Point", - coordinates: [tripEndPoint.data.longitude, tripEndPoint.data.latitude], - }, - } - }); - } - - var linkTrips = function(trip1, trip2) { - // complete trip1 - 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.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) { - $ionicLoading.show({ - 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); - /* - 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) { - console.log(JSON.stringify(trip)); - }); - 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 - // 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); - /* 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) - ); - 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]); - } - 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]); - } - $ionicLoading.hide(); - Logger.log("Returning final list of size "+trip_gj_list.length); - return trip_gj_list; - }); - } - }); - } - - var localCacheReadFn = timeline.updateFromDatabase; - - 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.getCompositeTrip = function(tripId) { - return angular.isDefined(timeline.data.infScrollCompositeTripMap)? timeline.data.infScrollCompositeTripMap[tripId] : undefined; - }; - - timeline.setInfScrollCompositeTripList = function(compositeTripList) { - timeline.data.infScrollCompositeTripList = compositeTripList; - - timeline.data.infScrollCompositeTripMap = {}; - - timeline.data.infScrollCompositeTripList.forEach(function(trip, index, array) { - timeline.data.infScrollCompositeTripMap[trip._id.$oid] = trip; - }); - } - - return timeline; - }) - diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index 579e3ac4f..6e82c0fbf 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -1,43 +1,61 @@ -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 { displayError, displayErrorMsg, logDebug } from '../plugin/logger'; +import { getBaseModeByKey, getBaseModeByValue } from './diaryHelper'; +import { getUnifiedDataForInterval } from '../services/unifiedDataLoader'; +import { getRawEntries } from '../services/commHelper'; +import { ServerResponse, BEMData } from '../types/serverData'; +import L, { LatLng } from 'leaflet'; +import { DateTime } from 'luxon'; +import { + UserInputEntry, + TripTransition, + TimelineEntry, + GeoJSONData, + FilteredLocation, + TimestampRange, + CompositeTrip, + UnprocessedTrip, + BluetoothBleData, + SectionData, + CompositeTripLocation, +} from '../types/diaryTypes'; +import { getLabelInputDetails, getLabelInputs } from '../survey/multilabel/confirmHelper'; +import { LabelOptions } from '../types/labelTypes'; +import { EnketoUserInputEntry, filterByNameAndVersion } from '../survey/enketo/enketoHelper'; +import { AppConfig } from '../types/appConfigTypes'; +import { Point, Feature } from 'geojson'; + +const cachedGeojsons: Map = new Map(); -const cachedGeojsons = new Map(); /** * @description Gets a formatted GeoJSON object for a trip, including the start and end places and the trajectory. */ -export function useGeojsonForTrip(trip, labelOptions, labeledMode?) { - if (!trip) return; - const gjKey = `trip-${trip._id.$oid}-${labeledMode || 'detected'}`; +export function useGeojsonForTrip(trip: CompositeTrip, baseMode?: string) { + if (!trip?._id?.$oid) return; + const gjKey = `trip-${trip._id.$oid}-${baseMode || 'detected'}`; if (cachedGeojsons.has(gjKey)) { return cachedGeojsons.get(gjKey); } - let trajectoryColor: string|null; - if (labeledMode) { - trajectoryColor = getBaseModeOfLabeledTrip(trip, labelOptions)?.color; - } + const trajectoryColor = (baseMode && getBaseModeByKey(baseMode)?.color) || undefined; - 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) + logDebug("Reading trip's " + trip.locations.length + ' location points at ' + new Date()); + const features = [ + location2GeojsonPoint(trip.start_loc, 'start_place'), + location2GeojsonPoint(trip.end_loc, 'end_place'), + ...locations2GeojsonTrajectory(trip, trip.locations, trajectoryColor), ]; - const gj = { + const gj: GeoJSONData = { 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; } @@ -50,7 +68,7 @@ export function useGeojsonForTrip(trip, labelOptions, labeledMode?) { * @param unpackPlaces whether to unpack the start and end places of each composite trip into the Map * @returns a Map() of timeline items, by id */ -export function compositeTrips2TimelineMap(ctList: any[], unpackPlaces?: boolean) { +export function compositeTrips2TimelineMap(ctList: Array, unpackPlaces?: boolean) { const timelineEntriesMap = new Map(); ctList.forEach((cTrip) => { if (unpackPlaces) { @@ -70,47 +88,44 @@ export function compositeTrips2TimelineMap(ctList: any[], unpackPlaces?: boolean return timelineEntriesMap; } -export function populateCompositeTrips(ctList, showPlaces, labelsFactory, labelsResultMap, notesFactory, notesResultMap) { - try { - ctList.forEach((ct, i) => { - if (showPlaces && ct.start_confirmed_place) { - const cp = ct.start_confirmed_place; - cp.getNextEntry = () => ctList[i]; - labelsFactory.populateInputsAndInferences(cp, labelsResultMap); - notesFactory.populateInputsAndInferences(cp, notesResultMap); - } - if (showPlaces && ct.end_confirmed_place) { - const cp = ct.end_confirmed_place; - cp.getNextEntry = () => ctList[i + 1]; - labelsFactory.populateInputsAndInferences(cp, labelsResultMap); - notesFactory.populateInputsAndInferences(cp, notesResultMap); - ct.getNextEntry = () => cp; - } else { - ct.getNextEntry = () => ctList[i + 1]; - } - labelsFactory.populateInputsAndInferences(ct, labelsResultMap); - notesFactory.populateInputsAndInferences(ct, notesResultMap); - }); - } catch (e) { - displayError(e, i18next.t('errors.while-populating-composite')); - } -} +/* 'LABELS' are 1:1 - each trip or place has a single label for each label type + (e.g. 'MODE' and 'PURPOSE' for MULTILABEL configuration, or 'SURVEY' for ENKETO configuration) */ +export let unprocessedLabels: { [key: string]: UserInputEntry[] } = {}; +/* 'NOTES' are 1:n - each trip or place can have any number of notes */ +export let unprocessedNotes: EnketoUserInputEntry[] = []; -const getUnprocessedInputQuery = (pipelineRange) => ({ - key: "write_ts", +const getUnprocessedInputQuery = (pipelineRange: TimestampRange) => ({ + key: 'write_ts', startTs: pipelineRange.end_ts - 10, - endTs: moment().unix() + 10 + endTs: DateTime.now().toUnixInteger() + 10, }); -function getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, notesPromises) { +/** + * updateUnprocessedInputs is a helper function for updateLocalUnprocessedInputs + * and updateAllUnprocessedInputs + */ +function updateUnprocessedInputs( + labelsPromises: Array>, + notesPromises: Array>, + appConfig: AppConfig, +) { return Promise.all([...labelsPromises, ...notesPromises]).then((comboResults) => { - const labelsConfirmResults = {}; - const notesConfirmResults = {}; const labelResults = comboResults.slice(0, labelsPromises.length); - const notesResults = comboResults.slice(labelsPromises.length); - labelsFactory.processManualInputs(labelResults, labelsConfirmResults); - notesFactory.processManualInputs(notesResults, notesConfirmResults); - return [labelsConfirmResults, notesConfirmResults]; + const notesResults = comboResults.slice(labelsPromises.length).flat(2); + // fill in the unprocessedLabels object with the labels we just read + labelResults.forEach((r, i) => { + if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') { + const filtered = filterByNameAndVersion('TripConfirmSurvey', r, appConfig); + unprocessedLabels['SURVEY'] = filtered as UserInputEntry[]; + } else { + unprocessedLabels[getLabelInputs()[i]] = r; + } + }); + // merge the notes we just read into the existing unprocessedNotes, removing duplicates + const combinedNotes = [...unprocessedNotes, ...notesResults]; + unprocessedNotes = combinedNotes.filter( + (note, i, self) => self.findIndex((n) => n.metadata.write_ts == note.metadata.write_ts) == i, + ); }); } @@ -119,21 +134,21 @@ function getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, note * pipeline range and have not yet been pushed to the server. * @param pipelineRange an object with start_ts and end_ts representing the range of time * for which travel data has been processed through the pipeline on the server - * @param labelsFactory the Angular factory for processing labels (MultilabelService or - * EnketoTripButtonService) - * @param notesFactory the Angular factory for processing notes (EnketoNotesButtonService) - * @returns Promise an array with 1) results for labels and 2) results for notes + * @param appConfig the app configuration */ -export function getLocalUnprocessedInputs(pipelineRange, labelsFactory, notesFactory) { +export async function updateLocalUnprocessedInputs( + pipelineRange: TimestampRange, + appConfig: AppConfig, +) { 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) + const labelsPromises = keysForLabelInputs(appConfig).map((key) => + BEMUserCache.getMessagesForInterval(key, tq, true), ); - const notesPromises = notesFactory.MANUAL_KEYS.map((key) => - BEMUserCache.getMessagesForInterval(key, tq, true).then(notesFactory.extractResult) + const notesPromises = keysForNotesInputs(appConfig).map((key) => + BEMUserCache.getMessagesForInterval(key, tq, true), ); - return getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, notesPromises); + await updateUnprocessedInputs(labelsPromises, notesPromises, appConfig); } /** @@ -141,21 +156,54 @@ export function getLocalUnprocessedInputs(pipelineRange, labelsFactory, notesFac * pipeline range, including those on the phone and that and have been pushed to the server but not yet processed. * @param pipelineRange an object with start_ts and end_ts representing the range of time * for which travel data has been processed through the pipeline on the server - * @param labelsFactory the Angular factory for processing labels (MultilabelService or - * EnketoTripButtonService) - * @param notesFactory the Angular factory for processing notes (EnketoNotesButtonService) - * @returns Promise an array with 1) results for labels and 2) results for notes + * @param appConfig the app configuration */ -export function getAllUnprocessedInputs(pipelineRange, labelsFactory, notesFactory) { - const UnifiedDataLoader = getAngularService('UnifiedDataLoader'); +export async function updateAllUnprocessedInputs( + pipelineRange: TimestampRange, + appConfig: AppConfig, +) { const tq = getUnprocessedInputQuery(pipelineRange); - const labelsPromises = labelsFactory.MANUAL_KEYS.map((key) => - UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true).then(labelsFactory.extractResult) + const getMethod = window['cordova'].plugins.BEMUserCache.getMessagesForInterval; + const labelsPromises = keysForLabelInputs(appConfig).map((key) => + getUnifiedDataForInterval(key, tq, getMethod), + ); + const notesPromises = keysForNotesInputs(appConfig).map((key) => + getUnifiedDataForInterval(key, tq, getMethod), ); - const notesPromises = notesFactory.MANUAL_KEYS.map((key) => - UnifiedDataLoader.getUnifiedMessagesForInterval(key, tq, true).then(notesFactory.extractResult) + await updateUnprocessedInputs(labelsPromises, notesPromises, appConfig); +} + +export let unprocessedBleScans: BEMData[] = []; + +export async function updateUnprocessedBleScans(queryRange: TimestampRange) { + const tq = { + key: 'write_ts', + startTs: queryRange.start_ts, + endTs: queryRange.end_ts, + }; + const getMethod = window['cordova'].plugins.BEMUserCache.getSensorDataForInterval; + await getUnifiedDataForInterval('background/bluetooth_ble', tq, getMethod).then( + (bleScans: BEMData[]) => { + logDebug(`Read ${bleScans.length} BLE scans`); + unprocessedBleScans = bleScans; + }, ); - return getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, notesPromises); +} + +export function keysForLabelInputs(appConfig: AppConfig) { + if (appConfig.survey_info?.['trip-labels'] == 'ENKETO') { + return ['manual/trip_user_input']; + } else { + return Object.values(getLabelInputDetails(appConfig)).map((inp) => inp.key); + } +} + +function keysForNotesInputs(appConfig: AppConfig) { + const notesKeys: string[] = []; + if (appConfig.survey_info?.buttons?.['trip-notes']) notesKeys.push('manual/trip_addition_input'); + if (appConfig.survey_info?.buttons?.['place-notes']) + notesKeys.push('manual/place_addition_input'); + return notesKeys; } /** @@ -163,15 +211,15 @@ export function getAllUnprocessedInputs(pipelineRange, labelsFactory, notesFacto * @param featureType a string describing the feature, e.g. "start_place" * @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", +const location2GeojsonPoint = (locationPoint: Point, featureType: string): Feature => ({ + type: 'Feature', geometry: { - type: "Point", + type: 'Point', coordinates: locationPoint.coordinates, }, properties: { feature_type: featureType, - } + }, }); /** @@ -180,33 +228,382 @@ const location2GeojsonPoint = (locationPoint: any, featureType: string) => ({ * @param trajectoryColor The color to use for the whole trajectory, if any. Otherwise, a color will be lookup up for the sensed mode of each section. * @returns for each section of the trip, a GeoJSON feature with type "LineString" and an array of coordinates. */ -const locations2GeojsonTrajectory = (trip, locationList, trajectoryColor?) => { - let sectionsPoints; +function locations2GeojsonTrajectory( + trip: CompositeTrip, + locationList: CompositeTripLocation[], + trajectoryColor?: string, +): Feature[] { + let sectionsPoints: CompositeTripLocation[][]; if (!trip.sections) { // this is a unimodal trip so we put all the locations in one section sectionsPoints = [locationList]; } 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', }, + properties: { + feature_type: 'section_trajectory', + }, + }; + }); +} + +// 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 unpackServerData = (obj: BEMData) => ({ + ...obj.data, + _id: obj._id, + key: obj.metadata.key, + origin_key: obj.metadata.origin_key || obj.metadata.key, +}); + +export function readAllCompositeTrips(startTs: number, endTs: number) { + const readPromises = [getRawEntries(['analysis/composite_trip'], startTs, endTs, 'data.end_ts')]; + return Promise.all(readPromises) + .then(([ctList]: [ServerResponse]) => { + return ctList.phone_data.map((ct) => { + const unpackedCt = unpackServerData(ct); + return { + ...unpackedCt, + start_confirmed_place: unpackServerData(unpackedCt.start_confirmed_place), + end_confirmed_place: unpackServerData(unpackedCt.end_confirmed_place), + locations: unpackedCt.locations?.map(unpackServerData), + sections: unpackedCt.sections?.map(unpackServerData), + }; + }); + }) + .catch((err) => { + displayError(err, 'while reading confirmed trips'); + return []; + }); +} + +const dateTime2localdate = (currtime: DateTime, tz: string) => ({ + timezone: tz, + year: currtime.year, + month: currtime.month, + day: currtime.day, + weekday: currtime.weekday, + hour: currtime.hour, + minute: currtime.minute, + second: currtime.second, +}); + +/** + * @description Given an array of location points, creates an UnprocessedTrip object. + */ +function points2UnprocessedTrip(locationPoints: Array>): UnprocessedTrip { + const startPoint = locationPoints[0]; + const endPoint = locationPoints[locationPoints.length - 1]; + const tripAndSectionId = `unprocessed_${startPoint.data.ts}_${endPoint.data.ts}`; + const startTime = DateTime.fromSeconds(startPoint.data.ts).setZone(startPoint.metadata.time_zone); + const endTime = DateTime.fromSeconds(endPoint.data.ts).setZone(endPoint.metadata.time_zone); + + const speeds: number[] = []; + const dists: number[] = []; + let loc, locLatLng: LatLng; + 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], + }, + ts: point.data.ts, + speed: speeds[i], + })); + + // baseProps: these are the properties that are the same between the trip and its section + const baseProps = { + distance: dists.reduce((a, b) => a + b, 0), + duration: endPoint.data.ts - startPoint.data.ts, + end_fmt_time: endTime.toISO() || displayErrorMsg('end_fmt_time: invalid DateTime') || '', + end_loc: { + type: 'Point', + coordinates: [endPoint.data.longitude, endPoint.data.latitude], + } as Point, + end_local_dt: dateTime2localdate(endTime, endPoint.metadata.time_zone), + end_ts: endPoint.data.ts, + source: 'unprocessed', + start_fmt_time: startTime.toISO() || displayErrorMsg('start_fmt_time: invalid DateTime') || '', + start_loc: { + type: 'Point', + coordinates: [startPoint.data.longitude, startPoint.data.latitude], + } as Point, + start_local_dt: dateTime2localdate(startTime, startPoint.metadata.time_zone), + start_ts: startPoint.data.ts, + } as const; + + // section: baseProps + some properties that are unique to the section + const singleSection: SectionData = { + ...baseProps, + _id: { $oid: `unprocessed_section_${tripAndSectionId}` }, + cleaned_section: { $oid: `unprocessed_section_${tripAndSectionId}` }, + key: 'UNPROCESSED_section', + origin_key: 'UNPROCESSED_section', + sensed_mode: 4, // MotionTypes.UNKNOWN (4) + sensed_mode_str: 'UNKNOWN', + trip_id: { $oid: tripAndSectionId }, + }; + + // the complete UnprocessedTrip: baseProps + properties that are unique to the trip, including the section + return { + ...baseProps, + _id: { $oid: tripAndSectionId }, + additions: [], + confidence_threshold: 0, + expectation: { to_label: true }, + inferred_labels: [], + key: 'UNPROCESSED_trip', + locations: locations, + origin_key: 'UNPROCESSED_trip', + sections: [singleSection], + user_input: {}, + }; +} + +const tsEntrySort = (e1: BEMData, e2: BEMData) => + e1.data.ts - e2.data.ts; // compare timestamps + +/** + * @description Given an array of 2 transitions, queries the location data during that time and promises an UnprocessedTrip object. + * @param trip An array of transitions representing one trip; i.e. [start transition, end transition] + */ +function tripTransitions2UnprocessedTrip(trip: Array): Promise { + const tripStartTransition = trip[0]; + const tripEndTransition = trip[1]; + const tq = { + key: 'write_ts', + startTs: tripStartTransition.data.ts, + endTs: tripEndTransition.data.ts, + }; + logDebug(`About to pull location data for range: + ${DateTime.fromSeconds(tripStartTransition.data.ts).toLocaleString(DateTime.DATETIME_MED)} + to + ${DateTime.fromSeconds(tripEndTransition.data.ts).toLocaleString(DateTime.DATETIME_MED)}`); + const getSensorData = window['cordova'].plugins.BEMUserCache.getSensorDataForInterval; + return getUnifiedDataForInterval('background/filtered_location', tq, getSensorData).then( + (locationList: Array>) => { + if (locationList.length == 0) { + return undefined; + } + const sortedLocationList = locationList.sort(tsEntrySort); + const retainInRange = (loc) => + tripStartTransition.data.ts <= loc.data.ts && loc.data.ts <= tripEndTransition.data.ts; + const filteredLocationList = sortedLocationList.filter(retainInRange); + + // Fix for https://github.com/e-mission/e-mission-docs/issues/417 + if (filteredLocationList.length == 0) { + return undefined; + } + + const tripStartPoint = filteredLocationList[0]; + const tripEndPoint = filteredLocationList[filteredLocationList.length - 1]; + logDebug(`tripStartPoint = ${tripStartPoint.data.ts} + tripEndPoint = ${tripEndPoint.data.ts}`); + // 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 + // https://github.com/e-mission/e-mission-docs/issues/417 + // if it ever occurs again + if (tripStartPoint === undefined || tripEndPoint === undefined) { + logDebug('BUG 417 check: locationList = ' + JSON.stringify(locationList)); + logDebug(`transitions: start = ${JSON.stringify(tripStartTransition.data)}; + end = ${JSON.stringify(tripEndTransition.data)}`); + } + return points2UnprocessedTrip(filteredLocationList); + }, + ); +} + +function isStartingTransition(transWrapper: BEMData) { + if ( + transWrapper.data.transition == 'local.transition.exited_geofence' || + transWrapper.data.transition == 'T_EXITED_GEOFENCE' || + transWrapper.data.transition == 1 + ) { + return true; + } + return false; +} + +function isEndingTransition(transWrapper: BEMData) { + // 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; + } + // Logger.log("Returning false"); + return false; +} + +/** + * @description Given an array of transitions, finds which transitions represent the start and end of a detected trip and returns them as pairs. + * @returns An 2D array of transitions, where each inner array represents one trip; i.e. [start transition, end transition] + */ +function transitions2TripTransitions(transitionList: Array>) { + /* 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. + */ + let inTrip = false; + const tripList: [BEMData, BEMData][] = []; + let currStartTransitionIndex = -1; + let currEndTransitionIndex = -1; + let processedUntil = 0; + + while (processedUntil < transitionList.length) { + // Logger.log("searching within list = "+JSON.stringify(transitionList.slice(processedUntil))); + if (inTrip == false) { + const foundStartTransitionIndex = transitionList + .slice(processedUntil) + .findIndex(isStartingTransition); + if (foundStartTransitionIndex == -1) { + logDebug('No further unprocessed trips started, exiting loop'); + processedUntil = transitionList.length; + } else { + currStartTransitionIndex = processedUntil + foundStartTransitionIndex; + processedUntil = currStartTransitionIndex; + logDebug(`'Unprocessed trip started at: + ${JSON.stringify(transitionList[currStartTransitionIndex])}`); + inTrip = true; + } + } else { + const foundEndTransitionIndex = transitionList + .slice(processedUntil) + .findIndex(isEndingTransition); + if (foundEndTransitionIndex == -1) { + logDebug(`Can't find end for trip starting at: + ${JSON.stringify(transitionList[currStartTransitionIndex])} - dropping it`); + processedUntil = transitionList.length; + } else { + currEndTransitionIndex = processedUntil + foundEndTransitionIndex; + processedUntil = currEndTransitionIndex; + logDebug(`currEndTransitionIndex ${currEndTransitionIndex}`); + logDebug(`Unprocessed trip, + starting at: ${JSON.stringify(transitionList[currStartTransitionIndex])}; + ends at: ${JSON.stringify(transitionList[currEndTransitionIndex])}`); + tripList.push([ + transitionList[currStartTransitionIndex], + transitionList[currEndTransitionIndex], + ]); + inTrip = false; + } + } + } + return tripList; +} + +function linkTrips(trip1, trip2) { + // complete trip1 + 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.enter_fmt_time = trip1.exit_fmt_time; + trip2.enter_local_dt = trip1.exit_local_dt; + trip2.enter_ts = trip1.exit_ts; +} + +export function readUnprocessedTrips( + startTs: number, + endTs: number, + lastProcessedTrip?: CompositeTrip, +) { + const tq = { key: 'write_ts', startTs, endTs }; + logDebug(`about to query for unprocessed trips from ${tq.startTs} to ${tq.endTs}`); + const getMessageMethod = window['cordova'].plugins.BEMUserCache.getMessagesForInterval; + return getUnifiedDataForInterval('statemachine/transition', tq, getMessageMethod).then( + (transitionList: Array>) => { + if (transitionList.length == 0) { + logDebug('No unprocessed trips. yay!'); + return []; + } else { + logDebug(`Found ${transitionList.length} transitions. yay!`); + const tripsList = transitions2TripTransitions(transitionList); + logDebug(`Mapped into ${tripsList.length} trips. yay!`); + tripsList.forEach((trip) => { + logDebug(JSON.stringify(trip, null, 2)); + }); + const tripFillPromises = tripsList.map(tripTransitions2UnprocessedTrip); + return Promise.all(tripFillPromises).then( + (rawTripObjs: (UnprocessedTrip | undefined)[]) => { + // 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 + // 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. + + logDebug(`mapping trips to tripObjs of size ${rawTripObjs.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 tripObjs = rawTripObjs.filter( + (trip) => trip && (trip.distance >= 100 || trip.duration >= 300), + ); + logDebug(`after filtering undefined and distance < 100m, + tripObjs size = ${tripObjs.length}`); + // Link 0th trip to first, first to second, ... + for (let i = 0; i < tripObjs.length - 1; i++) { + linkTrips(tripObjs[i], tripObjs[i + 1]); + } + logDebug(`finished linking trips for list of size ${tripObjs.length}`); + if (lastProcessedTrip && tripObjs.length != 0) { + // Need to link the entire chain above to the processed data + logDebug('linking unprocessed and processed trip chains'); + linkTrips(lastProcessedTrip, tripObjs[0]); + } + logDebug(`Returning final list of size ${tripObjs.length}`); + return tripObjs; + }, + ); + } + }, + ); } diff --git a/www/js/diary/useDerivedProperties.tsx b/www/js/diary/useDerivedProperties.tsx index 604fef227..a6985a8e5 100644 --- a/www/js/diary/useDerivedProperties.tsx +++ b/www/js/diary/useDerivedProperties.tsx @@ -1,10 +1,19 @@ -import { useMemo } from "react"; -import { useImperialConfig } from "../config/useImperialConfig"; -import { getFormattedDate, getFormattedDateAbbr, getFormattedSectionProperties, getFormattedTimeRange, getLocalTimeString, getDetectedModes, isMultiDay } from "./diaryHelper"; +import { useContext, useMemo } from 'react'; +import { useImperialConfig } from '../config/useImperialConfig'; +import { + getFormattedDate, + getFormattedDateAbbr, + getFormattedSectionProperties, + getFormattedTimeRange, + getLocalTimeString, + getDetectedModes, + isMultiDay, +} from './diaryHelper'; +import LabelTabContext from './LabelTabContext'; const useDerivedProperties = (tlEntry) => { - const imperialConfig = useImperialConfig(); + const { confirmedModeFor } = useContext(LabelTabContext); return useMemo(() => { const beginFmt = tlEntry.start_fmt_time || tlEntry.enter_fmt_time; @@ -12,8 +21,9 @@ 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 { + confirmedMode: confirmedModeFor(tlEntry), displayDate: getFormattedDate(beginFmt, endFmt), displayStartTime: getLocalTimeString(beginDt), displayEndTime: getLocalTimeString(endDt), @@ -24,8 +34,8 @@ const useDerivedProperties = (tlEntry) => { formattedSectionProperties: getFormattedSectionProperties(tlEntry, imperialConfig), distanceSuffix: imperialConfig.distanceSuffix, detectedModes: getDetectedModes(tlEntry), - } - }, [tlEntry, imperialConfig]); -} + }; + }, [tlEntry, imperialConfig, confirmedModeFor(tlEntry)]); +}; export default useDerivedProperties; diff --git a/www/js/i18n-utils.js b/www/js/i18n-utils.js deleted file mode 100644 index 45cca7043..000000000 --- a/www/js/i18n-utils.js +++ /dev/null @@ -1,40 +0,0 @@ -'use strict'; - -import angular from 'angular'; - -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 \/'); - } - - 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/"; - 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); - }); - } - return Promise.resolve(defaultVal); - } - return iu; -}); diff --git a/www/js/i18nextInit.ts b/www/js/i18nextInit.ts index 48177caf5..e42a130ac 100644 --- a/www/js/i18nextInit.ts +++ b/www/js/i18nextInit.ts @@ -15,22 +15,22 @@ On dev builds, the fallback translation is prefixed with a globe emoji so it's e and we can fix it. On prod builds, we'll just show the English string. */ /* any strings defined in fallbackLang but not in lang will be merged into lang, recursively */ -const mergeInTranslations = (lang, fallbackLang) => { +function mergeInTranslations(lang, fallbackLang) { Object.entries(fallbackLang).forEach(([key, value]) => { if (lang[key] === undefined) { - console.warn(`Missing translation for key '${key}'`); + logWarn(`Missing translation for key '${key}'`); if (__DEV__) { if (typeof value === 'string') { - lang[key] = `šŸŒ${value}` - } else if (typeof value === 'object') { + lang[key] = `šŸŒ${value}`; + } else if (typeof value === 'object' && typeof lang[key] === 'object') { lang[key] = {}; mergeInTranslations(lang[key], value); } } else { lang[key] = value; } - } else if (typeof value === 'object') { - mergeInTranslations(lang[key], fallbackLang[key]) + } else if (typeof value === 'object' && typeof lang[key] === 'object') { + mergeInTranslations(lang[key], fallbackLang[key]); } }); return lang; @@ -38,14 +38,10 @@ const mergeInTranslations = (lang, fallbackLang) => { import enJson from '../i18n/en.json'; import esJson from '../../locales/es/i18n/es.json'; -import frJson from '../../locales/fr/i18n/fr.json'; -import itJson from '../../locales/it/i18n/it.json'; import loJson from '../../locales/lo/i18n/lo.json'; const langs = { en: { translation: enJson }, es: { translation: mergeInTranslations(esJson, enJson) }, - fr: { translation: mergeInTranslations(frJson, enJson) }, - it: { translation: mergeInTranslations(itJson, enJson) }, lo: { translation: mergeInTranslations(loJson, enJson) }, }; @@ -59,22 +55,25 @@ 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'; +import { logWarn } from './plugin/logger'; 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 deleted file mode 100644 index 94bb8aeaf..000000000 --- a/www/js/main.js +++ /dev/null @@ -1,31 +0,0 @@ -'use strict'; - -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', - 'emission.services.upload']) - -.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); - }); - } -}); diff --git a/www/js/metrics-factory.js b/www/js/metrics-factory.js deleted file mode 100644 index ce813fbaa..000000000 --- a/www/js/metrics-factory.js +++ /dev/null @@ -1,238 +0,0 @@ -'use strict'; - -import angular from 'angular'; -import { getBaseModeByValue } from './diary/diaryHelper' -import { labelOptions } from './survey/multilabel/confirmHelper'; - -angular.module('emission.main.metrics.factory', - ['emission.main.metrics.mappings', - 'emission.plugin.kvstore']) - -.factory('FootprintHelper', function(CarbonDatasetHelper, CustomDatasetHelper) { - var fh = {}; - var highestFootprint = 0; - - var mtokm = function(v) { - return v / 1000; - } - fh.useCustom = false; - - fh.setUseCustomFootprint = function () { - fh.useCustom = true; - } - - fh.getFootprint = function() { - if (this.useCustom == true) { - return CustomDatasetHelper.getCustomFootprint(); - } 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 - } - else { - lowestFootprint = Math.min(lowestFootprint, footprint[mode]); - } - } - return lowestFootprint * mtokm(distance); - } - - fh.getHighestFootprint = function() { - if (!highestFootprint) { - var footprint = fh.getFootprint(); - let footprintList = []; - for (var mode in footprint) { - 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); - } 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) { - const defaultFootprint = CarbonDatasetHelper.getCurrentCarbonDatasetFootprint(); - const lowestMotorizedNonAir = getLowestMotorizedNonAirFootprint(defaultFootprint); - 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 { - // custom footprint, let's get the custom values - const customFootprint = CustomDatasetHelper.getCustomFootprint(); - 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; - } - 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}]; - } 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}]; - } - } - } - - return fh; -}) - -.factory('CalorieCal', function(KVStore, METDatasetHelper, CustomDatasetHelper) { - - var cc = {}; - var highestMET = 0; - var USER_DATA_KEY = "user-data"; - cc.useCustom = false; - - cc.setUseCustomFootprint = function () { - cc.useCustom = true; - } - - cc.getMETs = function() { - if (this.useCustom == true) { - return CustomDatasetHelper.getCustomMETs(); - } else { - return METDatasetHelper.getStandardMETs(); - } - } - - cc.set = function(info) { - return KVStore.set(USER_DATA_KEY, info); - }; - cc.get = function() { - return KVStore.get(USER_DATA_KEY); - }; - cc.delete = function() { - return KVStore.remove(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); - } - } - 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; -}); diff --git a/www/js/metrics-mappings.js b/www/js/metrics-mappings.js deleted file mode 100644 index 2b71df739..000000000 --- a/www/js/metrics-mappings.js +++ /dev/null @@ -1,401 +0,0 @@ -import angular from 'angular'; -import { getLabelOptions } from './survey/multilabel/confirmHelper'; -import { getConfig } from './config/dynamicConfig'; - -angular.module('emission.main.metrics.mappings', ['emission.plugin.logger', - 'emission.plugin.kvstore']) - -.service('CarbonDatasetHelper', function(KVStore) { - 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) - } - } - }; - - 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; - } - } - } - - this.loadCarbonDatasetLocale = function() { - return KVStore.get(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); - KVStore.set(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.getCurrentCarbonDatasetCode = function () { - return currentCarbonDatasetCode; - }; - - this.getCurrentCarbonDatasetFootprint = function () { - return carbonDatasets[currentCarbonDatasetCode].footprintData; - }; -}) -.service('METDatasetHelper', function(KVStore) { - 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 - } - }, - "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 - } - }, - "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) { - var cdh = {}; - - 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.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.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); - } - 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) { - try { - getLabelOptions(newConfig).then((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); - }, 1000); - } - } - - $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..92a6ac768 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..56e955f60 100644 --- a/www/js/metrics/CarbonFootprintCard.tsx +++ b/www/js/metrics/CarbonFootprintCard.tsx @@ -1,168 +1,230 @@ import React, { useState, useMemo } from 'react'; import { View } from 'react-native'; -import { Card, Text, useTheme} from 'react-native-paper'; +import { Card, Text } from 'react-native-paper'; import { MetricsData } from './metricsTypes'; import { cardStyles } from './MetricsTab'; -import { formatDateRangeOfDays, parseDataFromMetrics, generateSummaryFromData, calculatePercentChange, segmentDaysByWeeks, isCustomLabels } from './metricsHelper'; +import { + getFootprintForMetrics, + getHighestFootprint, + getHighestFootprintForDistance, +} from './footprintHelper'; +import { + formatDateRangeOfDays, + parseDataFromMetrics, + generateSummaryFromData, + calculatePercentChange, + segmentDaysByWeeks, + isCustomLabels, + MetricsSummary, +} 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 ChangeIndicator, { CarbonChange } from './ChangeIndicator'; +import color from 'color'; +import { useAppTheme } from '../appTheme'; +import { logDebug, logWarn } from '../plugin/logger'; -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 { colors } = useAppTheme(); + const { t } = useTranslation(); + + const [emissionsChange, setEmissionsChange] = useState(undefined); + + const userCarbonRecords = useMemo(() => { + if (userMetrics?.distance?.length) { + //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: { label: string; x: number | string; y: number | string }[] = []; + + //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) + let userPrevWeek; + if (userLastWeekSummaryMap[0]) { + userPrevWeek = { + low: getFootprintForMetrics(userLastWeekSummaryMap, 0), + high: getFootprintForMetrics(userLastWeekSummaryMap, 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: getFootprintForMetrics(userThisWeekSummaryMap, 0), + high: getFootprintForMetrics(userThisWeekSummaryMap, 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 = 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) { + //separate data into weeks + const thisWeekDistance = segmentDaysByWeeks(aggMetrics?.distance, 1)[0]; + logDebug(`groupCarbonRecords: aggMetrics = ${JSON.stringify(aggMetrics)}; + thisWeekDistance = ${JSON.stringify(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: MetricsSummary[] = []; + for (let i in aggThisWeekSummary) { + aggCarbonData.push(aggThisWeekSummary[i]); + if (isNaN(aggCarbonData[i].values)) { + logWarn(`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: { label: string; x: number | string; y: number | string }[] = []; + + let aggCarbon = { + low: getFootprintForMetrics(aggCarbonData, 0), + high: getFootprintForMetrics(aggCarbonData, getHighestFootprint()), + }; + logDebug(`groupCarbonRecords: aggCarbon = ${JSON.stringify(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: { label: string; x: number | string; y: number | string }[] = []; + if (userCarbonRecords?.length) { + tempChartData = tempChartData.concat(userCarbonRecords); + } + if (groupCarbonRecords?.length) { + tempChartData = tempChartData.concat(groupCarbonRecords); + } + tempChartData = tempChartData.reverse(); + return tempChartData; + }, [userCarbonRecords, groupCarbonRecords]); + + const cardSubtitleText = useMemo(() => { + if (!aggMetrics?.distance?.length) return; + 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..bf89bdb49 100644 --- a/www/js/metrics/CarbonTextCard.tsx +++ b/www/js/metrics/CarbonTextCard.tsx @@ -1,151 +1,183 @@ 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 { getAngularService } from '../angular-react-helper'; +import { + getFootprintForMetrics, + getHighestFootprint, + getHighestFootprintForDistance, +} from './footprintHelper'; +import { + formatDateRangeOfDays, + parseDataFromMetrics, + generateSummaryFromData, + calculatePercentChange, + segmentDaysByWeeks, + MetricsSummary, +} from './metricsHelper'; +import { logDebug, logWarn } from '../plugin/logger'; -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 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) { + //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: { label: string; value: string }[] = []; + + //calculate low-high and format range for prev week, if exists (14 days ago -> 8 days ago) + if (userLastWeekSummaryMap[0]) { + let userPrevWeek = { + low: getFootprintForMetrics(userLastWeekSummaryMap, 0), + high: getFootprintForMetrics(userLastWeekSummaryMap, 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: getFootprintForMetrics(userThisWeekSummaryMap, 0), + high: getFootprintForMetrics(userThisWeekSummaryMap, 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 = 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) { + //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: MetricsSummary[] = []; + for (let i in aggThisWeekSummary) { + aggCarbonData.push(aggThisWeekSummary[i]); + if (isNaN(aggCarbonData[i].values)) { + logWarn(`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: { label: string; value: string }[] = []; - return groupText; + let aggCarbon = { + low: getFootprintForMetrics(aggCarbonData, 0), + high: getFootprintForMetrics(aggCarbonData, getHighestFootprint()), + }; + logDebug(`groupText: aggCarbon = ${JSON.stringify(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: { label: string; value: string }[] = []; + 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(); + if (!aggMetrics?.distance?.length) return; + 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..137113ac1 100644 --- a/www/js/metrics/ChangeIndicator.tsx +++ b/www/js/metrics/ChangeIndicator.tsx @@ -1,79 +1,65 @@ -import React, {useMemo} from 'react'; +import React, { useMemo } from 'react'; import { View } from 'react-native'; -import { useTheme, Text } from "react-native-paper"; +import { Text } from 'react-native-paper'; import { useTranslation } from 'react-i18next'; -import colorLib from "color"; +import colorLib from 'color'; +import { useAppTheme } from '../appTheme'; -type Props = { - change: {low: number, high: number}, -} +export type CarbonChange = { low: number; high: number } | undefined; +type Props = { change: CarbonChange }; -const ChangeIndicator = ({ change }) => { - const { colors } = useTheme(); - const { t } = useTranslation(); +const ChangeIndicator = ({ change }: Props) => { + const { colors } = useAppTheme(); + const { t } = useTranslation(); - const changeSign = function(changeNum) { - if(changeNum > 0) { - return "+"; - } else { - return "-"; - } - }; + const changeSign = (changeNum) => (changeNum > 0 ? '+' : '-'); - 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) return; + 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..c6ba7cbf0 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,55 +9,60 @@ 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 records: { label: string; x: string; y: number }[] = []; 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), - x: day.ts * 1000, // vertical chart, milliseconds on X axis - y: activeSeconds && activeSeconds / 60, // minutes on Y axis - }); + if (activeSeconds) { + records.push({ + label: labelKeyToRichMode(mode), + x: `${day.ts * 1000}`, // vertical chart, milliseconds on X axis + y: activeSeconds && activeSeconds / 60, // minutes on Y axis + }); + } }); }); - 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..6662762c2 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,41 +12,49 @@ 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), - x: unitFormatFn ? unitFormatFn(rawVal) : rawVal, - y: day.ts * 1000, // time (as milliseconds) will go on Y axis because it will be a horizontal chart - }); + if (rawVal) { + records.push({ + label: labelKeyToRichMode(label), + x: unitFormatFn ? unitFormatFn(rawVal) : rawVal, + y: day.ts * 1000, // time (as milliseconds) will go on Y axis because it will be a horizontal chart + }); + } }); }); // 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 +62,9 @@ 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'); + if (!metricDataDays) return; + const groupText = + populationMode == 'user' ? t('main-metrics.user-totals') : t('main-metrics.group-totals'); return `${groupText} (${formatDateRangeOfDays(metricDataDays)})`; }, [metricDataDays, populationMode]); @@ -67,10 +75,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 +85,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..07656ec25 100644 --- a/www/js/metrics/MetricsDateSelect.tsx +++ b/www/js/metrics/MetricsDateSelect.tsx @@ -6,66 +6,91 @@ 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 { DateTime } from 'luxon'; +import { NavBarButton } from '../components/NavBar'; 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 }) => { + const dtStartDate = DateTime.fromJSDate(startDate).startOf('day'); + let dtEndDate; + + if (!endDate) { + // If no end date selected, pull range from then till present day + dtEndDate = DateTime.now(); + } else if ( + dtStartDate.toString() === DateTime.fromJSDate(endDate).startOf('day').toString() + ) { + // For when only one day is selected + // NOTE: As written, this technically timestamp will technically fetch _two_ days. + // For more info, see: https://github.com/e-mission/e-mission-docs/issues/1027 + dtEndDate = dtStartDate.endOf('day'); + } else { + dtEndDate = DateTime.fromJSDate(endDate).startOf('day'); + } + setOpen(false); + setDateRange([dtStartDate, dtEndDate]); + }, + [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', + width: 25, marginHorizontal: 'auto', - } + }, }); export default MetricsDateSelect; diff --git a/www/js/metrics/MetricsTab.tsx b/www/js/metrics/MetricsTab.tsx index 748db2b99..7533022a5 100644 --- a/www/js/metrics/MetricsTab.tsx +++ b/www/js/metrics/MetricsTab.tsx @@ -1,36 +1,41 @@ -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 React, { useEffect, useState, useMemo } from 'react'; +import { View, ScrollView, useWindowDimensions } from 'react-native'; +import { Appbar, useTheme } from 'react-native-paper'; +import { useTranslation } from 'react-i18next'; +import { DateTime } from 'luxon'; +import NavBar from '../components/NavBar'; +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 '../services/commHelper'; +import { displayError, logDebug, logWarn } from '../plugin/logger'; +import useAppConfig from '../useAppConfig'; +import { ServerConnConfig } from '../types/appConfigTypes'; export const METRIC_LIST = ['duration', 'mean_speed', 'count', 'distance'] as const; -async function fetchMetricsFromServer(type: 'user'|'aggregate', dateRange: DateTime[]) { - const CommHelper = getAngularService('CommHelper'); +async function fetchMetricsFromServer( + type: 'user' | 'aggregate', + dateRange: DateTime[], + serverConnConfig: ServerConnConfig, +) { 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 CommHelper.getMetrics('timestamp', query); - return CommHelper.getAggregateData("result/metrics/timestamp", query); + is_return_aggregate: type == 'aggregate', + }; + if (type == 'user') return getMetrics('timestamp', query); + return getAggregateData('result/metrics/timestamp', query, serverConnConfig); } function getLastTwoWeeksDtRange() { @@ -41,32 +46,45 @@ function getLastTwoWeeksDtRange() { } const MetricsTab = () => { - + const appConfig = useAppConfig(); + const { colors } = useTheme(); 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); - const [userMetrics, setUserMetrics] = useState(null); + const [aggMetrics, setAggMetrics] = useState(undefined); + const [userMetrics, setUserMetrics] = useState(undefined); useEffect(() => { + if (!appConfig?.server) return; loadMetricsForPopulation('user', dateRange); loadMetricsForPopulation('aggregate', dateRange); - }, [dateRange]); + }, [dateRange, appConfig?.server]); - async function loadMetricsForPopulation(population: 'user'|'aggregate', dateRange: DateTime[]) { - const serverResponse = await fetchMetricsFromServer(population, dateRange); - console.debug("Got metrics = ", serverResponse); - const metrics = {}; - const dataKey = (population == 'user') ? 'user_metrics' : 'aggregate_metrics'; - METRIC_LIST.forEach((metricName, i) => { - metrics[metricName] = serverResponse[dataKey][i]; - }); - if (population == 'user') { - setUserMetrics(metrics as MetricsData); - } else { - setAggMetrics(metrics as MetricsData); + async function loadMetricsForPopulation(population: 'user' | 'aggregate', dateRange: DateTime[]) { + try { + logDebug(`MetricsTab: fetching metrics for population ${population}' + in date range ${JSON.stringify(dateRange)}`); + const serverResponse: any = await fetchMetricsFromServer( + population, + dateRange, + appConfig.server, + ); + logDebug('MetricsTab: received metrics: ' + JSON.stringify(serverResponse)); + const metrics = {}; + const dataKey = population == 'user' ? 'user_metrics' : 'aggregate_metrics'; + METRIC_LIST.forEach((metricName, i) => { + metrics[metricName] = serverResponse[dataKey][i]; + }); + logDebug('MetricsTab: parsed metrics: ' + JSON.stringify(metrics)); + if (population == 'user') { + setUserMetrics(metrics as MetricsData); + } else { + setAggMetrics(metrics as MetricsData); + } + } catch (e) { + logWarn(e + t('errors.while-loading-metrics')); // replace with displayErr } } @@ -75,49 +93,57 @@ const MetricsTab = () => { } const { width: windowWidth } = useWindowDimensions(); - const cardWidth = windowWidth * .88; + const cardWidth = windowWidth * 0.88; - return (<> - - - - - - - - - - - - - - - - - - - - {/* + + + + + + + + + + + + + + + + + + + + {/* */} - - - ); -} + + + + ); +}; export const cardMargin = 10; @@ -134,7 +160,7 @@ export const cardStyles: any = { titleText: (colors) => ({ color: colors.onPrimary, fontWeight: '500', - textAlign: 'center' + textAlign: 'center', }), subtitleText: { fontSize: 13, @@ -146,7 +172,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..eb1a29939 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,72 @@ 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); + if (!userMetrics?.duration) return []; + const records: { x: string; y: number; label: string }[] = []; + 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}); + // `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(lastWeekDistance)})` + const xLabel = `${t('main-metrics.prev-week')}\n(${formatDateRangeOfDays(prevWeek)})`; + 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}); + const xLabel = `${t('main-metrics.past-week')}\n(${formatDateRangeOfDays(recentWeek)})`; + 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/customMetricsHelper.ts b/www/js/metrics/customMetricsHelper.ts new file mode 100644 index 000000000..096a62cb4 --- /dev/null +++ b/www/js/metrics/customMetricsHelper.ts @@ -0,0 +1,109 @@ +import { getLabelOptions } from '../survey/multilabel/confirmHelper'; +import { displayError, logDebug, logWarn } from '../plugin/logger'; +import { standardMETs } from './metDataset'; +import { AppConfig } from '../types/appConfigTypes'; + +//variables to store values locally +let _customMETs: { [key: string]: { [key: string]: { range: number[]; met: number } } }; +let _customPerKmFootprint: { [key: string]: number }; +let _labelOptions; + +/** + * ONLY USED IN TESTING + * @function clears the locally stored variables + */ +export function _test_clearCustomMetrics() { + _customMETs = undefined; + _customPerKmFootprint = undefined; + _labelOptions = undefined; +} + +/** + * @function gets custom mets, must be initialized + * @returns the custom mets stored locally + */ +export function getCustomMETs() { + logDebug('Getting custom METs ' + JSON.stringify(_customMETs)); + return _customMETs; +} + +/** + * @function gets the custom footprint, must be initialized + * @returns custom footprint + */ +export function getCustomFootprint() { + logDebug('Getting custom footprint ' + JSON.stringify(_customPerKmFootprint)); + return _customPerKmFootprint; +} + +/** + * @function stores custom mets in local var + * needs _labelOptions, stored after gotten from config + */ +function populateCustomMETs() { + let modeOptions = _labelOptions['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) { + currMET[rangeName].range = currMET[rangeName].range.map((i) => + i == -1 ? Number.MAX_VALUE : i, + ); + } + return [opt.value, currMET]; + } else { + logWarn(`Did not find either met_equivalent or met for ${opt.value} ignoring entry`); + return undefined; + } + } + }); + _customMETs = Object.fromEntries(modeMETEntries.filter((e) => typeof e !== 'undefined')); + logDebug('After populating, custom METs = ' + JSON.stringify(_customMETs)); +} + +/** + * @function stores custom footprint in local var + * needs _inputParams which is stored after gotten from config + */ +function populateCustomFootprints() { + let modeOptions = _labelOptions['MODE']; + let modeCO2PerKm = modeOptions + .map((opt) => { + if (typeof opt.kgCo2PerKm !== 'undefined') { + return [opt.value, opt.kgCo2PerKm]; + } else { + return undefined; + } + }) + .filter((modeCO2) => typeof modeCO2 !== 'undefined'); + _customPerKmFootprint = Object.fromEntries(modeCO2PerKm); + logDebug('After populating, custom perKmFootprint' + JSON.stringify(_customPerKmFootprint)); +} + +/** + * @function initializes the datasets based on configured label options + * calls popuplateCustomMETs and populateCustomFootprint + * @param newConfig the app config file + */ +export async function initCustomDatasetHelper(newConfig: AppConfig) { + try { + logDebug('initializing custom datasets with config' + newConfig); + const labelOptions = await getLabelOptions(newConfig); + logDebug('In custom metrics, label options = ' + JSON.stringify(labelOptions)); + _labelOptions = labelOptions; + populateCustomMETs(); + populateCustomFootprints(); + } catch (e) { + setTimeout(() => { + displayError(e, 'Error while initializing custom dataset helper'); + }, 1000); + } +} diff --git a/www/js/metrics/footprintHelper.ts b/www/js/metrics/footprintHelper.ts new file mode 100644 index 000000000..c37d8de92 --- /dev/null +++ b/www/js/metrics/footprintHelper.ts @@ -0,0 +1,95 @@ +import { displayError, displayErrorMsg, logDebug, logWarn } from '../plugin/logger'; +import { getCustomFootprint } from './customMetricsHelper'; + +//variables for the highest footprint in the set and if using custom +let highestFootprint: number | undefined = 0; + +/** + * @function converts meters to kilometers + * @param {number} v value in meters to be converted + * @returns {number} converted value in km + */ +const mtokm = (v) => v / 1000; + +/** + * @function clears the stored highest footprint + */ +export function clearHighestFootprint() { + //need to clear for testing + highestFootprint = undefined; +} + +/** + * @function gets the footprint + * currently will only be custom, as all labels are "custom" + * @returns the footprint or undefined + */ +function getFootprint() { + let footprint = getCustomFootprint(); + if (footprint) { + return footprint; + } else { + throw new Error('In Footprint Calculatins, failed to use custom labels'); + } +} + +/** + * @function calculates footprint for given metrics + * @param {Array} userMetrics string mode + number distance in meters pairs + * ex: const custom_metrics = [ { key: 'walk', values: 3000 }, { key: 'bike', values: 6500 }, ]; + * @param {number} defaultIfMissing optional, carbon intensity if mode not in footprint + * @returns {number} the sum of carbon emissions for userMetrics given + */ +export function getFootprintForMetrics(userMetrics, defaultIfMissing = 0) { + const footprint = getFootprint(); + logDebug('getting footprint for ' + userMetrics + ' with ' + footprint); + let result = 0; + for (let i in userMetrics) { + let mode = userMetrics[i].key; + + //either the mode is in our custom footprint or it is not + if (mode in footprint) { + result += footprint[mode] * mtokm(userMetrics[i].values); + } else if (mode == 'IN_VEHICLE') { + const sum = + footprint['CAR'] + + footprint['BUS'] + + footprint['LIGHT_RAIL'] + + footprint['TRAIN'] + + footprint['TRAM'] + + footprint['SUBWAY']; + result += (sum / 6) * mtokm(userMetrics[i].values); + } else { + logWarn( + `WARNING getFootprintFromMetrics() was requested for an unknown mode: ${mode} metrics JSON: ${JSON.stringify( + userMetrics, + )}`, + ); + result += defaultIfMissing * mtokm(userMetrics[i].values); + } + } + return result; +} + +/** + * @function gets highest co2 intensity in the footprint + * @returns {number} the highest co2 intensity in the footprint + */ +export function getHighestFootprint() { + if (!highestFootprint) { + const footprint = getFootprint(); + let footprintList: number[] = []; + for (let mode in footprint) { + footprintList.push(footprint[mode]); + } + highestFootprint = Math.max(...footprintList); + } + return highestFootprint; +} + +/** + * @function gets highest theoretical footprint for given distance + * @param {number} distance in meters to calculate max footprint + * @returns max footprint for given distance + */ +export const getHighestFootprintForDistance = (distance) => getHighestFootprint() * mtokm(distance); diff --git a/www/js/metrics/metDataset.ts b/www/js/metrics/metDataset.ts new file mode 100644 index 000000000..901c17ae6 --- /dev/null +++ b/www/js/metrics/metDataset.ts @@ -0,0 +1,128 @@ +export const 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, + }, + }, + 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, + }, + }, + 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, + }, + }, +}; diff --git a/www/js/metrics/metHelper.ts b/www/js/metrics/metHelper.ts new file mode 100644 index 000000000..25bcc2e7e --- /dev/null +++ b/www/js/metrics/metHelper.ts @@ -0,0 +1,59 @@ +import { logDebug, logWarn } from '../plugin/logger'; +import { getCustomMETs } from './customMetricsHelper'; +import { standardMETs } from './metDataset'; + +/** + * @function gets the METs object + * @returns {object} mets either custom or standard + */ +function getMETs() { + let custom_mets = getCustomMETs(); + if (custom_mets) { + return custom_mets; + } else { + return standardMETs; + } +} + +/** + * @function checks number agains bounds + * @param num the number to check + * @param min lower bound + * @param max upper bound + * @returns {boolean} if number is within given bounds + */ +const between = (num, min, max) => num >= min && num <= max; + +/** + * @function converts meters per second to miles per hour + * @param mps meters per second speed + * @returns speed in miles per hour + */ +const mpstomph = (mps) => 2.23694 * mps; + +/** + * @function gets met for a given mode and speed + * @param {string} mode of travel + * @param {number} speed of travel in meters per second + * @param {number} defaultIfMissing default MET if mode not in METs + * @returns + */ +export function getMet(mode, speed, defaultIfMissing) { + if (mode == 'ON_FOOT') { + logDebug("getMet() converted 'ON_FOOT' to 'WALKING'"); + mode = 'WALKING'; + } + let currentMETs = getMETs(); + if (!currentMETs[mode]) { + logWarn('getMet() Illegal mode: ' + mode); + return defaultIfMissing; //So the calorie sum does not break with wrong return type + } + for (let i in currentMETs[mode]) { + if (between(mpstomph(speed), currentMETs[mode][i].range[0], currentMETs[mode][i].range[1])) { + return currentMETs[mode][i].mets; + } else if (mpstomph(speed) < 0) { + logWarn('getMet() Negative speed: ' + mpstomph(speed)); + return 0; + } + } +} diff --git a/www/js/metrics/metricsHelper.ts b/www/js/metrics/metricsHelper.ts index d1cd435d4..ca3846806 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 moment from 'moment'; +import { DateTime } from 'luxon'; +import { formatForDisplay } from '../config/useImperialConfig'; +import { DayOfMetricData } from './metricsTypes'; +import { logDebug } from '../plugin/logger'; 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,23 +58,17 @@ 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"){ - return metric[field]; - } - else{ - return metric[field]/metric.nUsers; - } -} + * 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 = (population: 'user' | 'aggregate', metric, field) => + population == 'user' ? metric[field] : metric[field] / metric.nUsers; //testing agains global list of what is "on foot" //returns true | false -const isOnFoot = function(mode: string) { +function isOnFoot(mode: string) { for (let ped_mode in ON_FOOT_MODES) { if (mode === ped_mode) { return true; @@ -89,22 +80,23 @@ const isOnFoot = function(mode: string) { //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); - let mode_bins = {}; - metrics?.forEach(function(metric) { + logDebug(`parseDataFromMetrics: metrics = ${JSON.stringify(metrics)}; + population = ${population}`); + let mode_bins: { [k: string]: [number, number, string][] } = {}; + metrics?.forEach((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,99 +106,98 @@ 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); + logDebug('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)), + DateTime.fromISO(metric.fmt_time).toISO() as string, + ]); } } //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 return_val; + return Object.entries(mode_bins).map(([key, values]) => ({ key, values })); } +export type MetricsSummary = { key: string; values: number }; export function generateSummaryFromData(modeMap, metric) { - console.log("Invoked getSummaryDataRaw on ", modeMap, "with", metric); - - let summaryMap = []; + logDebug(`Invoked getSummaryDataRaw on ${JSON.stringify(modeMap)} with ${metric}`); - for (let i=0; i < modeMap.length; i++){ - let summary = {}; - summary['key'] = modeMap[i].key; - let sumVals = 0; + let summaryMap: MetricsSummary[] = []; - for (let j = 0; j < modeMap[i].values.length; j++) - { - sumVals += modeMap[i].values[j][1]; //2nd item of array is value + for (let i = 0; i < modeMap.length; i++) { + let vals = 0; + for (let j = 0; j < modeMap[i].values.length; j++) { + vals += modeMap[i].values[j][1]; //2nd item of array is value } - if (metric === 'mean_speed'){ - //we care about avg speed, sum for other metrics - summary['values'] = Math.round(sumVals / modeMap[i].values.length); - } else { - summary['values'] = Math.round(sumVals); + if (metric === 'mean_speed') { + // For speed, we take the avg. For other metrics we keep the sum + vals = vals / modeMap[i].values.length; } - - summaryMap.push(summary); + summaryMap.push({ + key: modeMap[i].key, + values: Math.round(vals), + }); } return summaryMap; } /* -* 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 function isCustomLabels(modeMap) { const isSensed = (mode) => mode == mode.toUpperCase(); const isCustom = (mode) => mode == mode.toLowerCase(); - const metricSummaryChecksCustom = []; - const metricSummaryChecksSensed = []; + const metricSummaryChecksCustom: boolean[] = []; + const metricSummaryChecksSensed: boolean[] = []; 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); + logDebug(`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); + metricSummaryChecksCustom.push(Boolean(isAllCustomForMetric)); + logDebug(`overall custom/not results for each metric + is ${JSON.stringify(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 +function isAllCustom(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..cce1cd243 100644 --- a/www/js/metrics/metricsTypes.ts +++ b/www/js/metrics/metricsTypes.ts @@ -1,14 +1,15 @@ -import { METRIC_LIST } from "./MetricsTab" +import { LocalDt } from '../types/serverData'; +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: LocalDt; +}; export type MetricsData = { - [key in MetricName]: DayOfMetricData[] -} + [key in MetricName]: DayOfMetricData[]; +}; diff --git a/www/js/ngApp.js b/www/js/ngApp.js deleted file mode 100644 index 9e5e5f29e..000000000 --- a/www/js/ngApp.js +++ /dev/null @@ -1,100 +0,0 @@ -// Ionic E-Mission App - -'use strict'; - -import angular from 'angular'; -import React from 'react'; -import { createRoot } from 'react-dom/client'; -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'; - -import { Provider as PaperProvider } from 'react-native-paper'; -import App from './App'; -import { getTheme } from './appTheme'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { initByUser } from './config/dynamicConfig'; - -angular.module('emission', ['ionic', 'jm.i18next', - 'emission.controllers','emission.services', 'emission.plugin.logger', - 'emission.splash.customURLScheme', 'emission.splash.referral', - 'emission.services.email', - 'emission.main', 'pascalprecht.translate', 'LocalStorageModule']) - -.run(function($ionicPlatform, $rootScope, $http, Logger, - CustomURLScheme, ReferralHandler, 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"); - // 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? - CustomURLScheme.onLaunch(function(event, url, urlComponents){ - console.log("GOT URL:"+url); - // alert("GOT URL:"+url); - - if (urlComponents.route == 'join') { - ReferralHandler.setupGroupReferral(urlComponents); - } else if (urlComponents.route == 'login_token') { - initByUser(urlComponents); - } - }); - // 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"); - - const rootEl = document.getElementById('appRoot'); - const reactRoot = createRoot(rootEl); - - const theme = getTheme(); - - reactRoot.render( - - - - - - - ); - }); - console.log("Ending run"); -}); diff --git a/www/js/onboarding/OnboardingStack.tsx b/www/js/onboarding/OnboardingStack.tsx index c547fd074..9682156ae 100644 --- a/www/js/onboarding/OnboardingStack.tsx +++ b/www/js/onboarding/OnboardingStack.tsx @@ -1,20 +1,17 @@ -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); - if (onboardingState.route == OnboardingRoute.WELCOME) { return ; } else if (onboardingState.route == OnboardingRoute.SUMMARY) { @@ -27,8 +24,9 @@ const OnboardingStack = () => { return ; } else { displayErrorMsg('OnboardingStack: unknown route', onboardingState.route); + return <>; } -} +}; export const onboardingStyles = StyleSheet.create({ page: { @@ -50,4 +48,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..a24449045 100644 --- a/www/js/onboarding/PrivacyPolicy.tsx +++ b/www/js/onboarding/PrivacyPolicy.tsx @@ -1,177 +1,229 @@ -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'; +import { useTheme } from 'react-native-paper'; const PrivacyPolicy = () => { - const { t, i18n } = useTranslation(); - const appConfig = useAppConfig(); + const { t, i18n } = useTranslation(); + const appConfig = useAppConfig(); + const { colors } = useTheme(); - 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})}; - } + // backwards compat hack to fill in the raw_data_use for programs that don't have it + if (appConfig?.intro) { + const default_raw_data_use = { + en: `monitor the ${appConfig?.intro?.program_or_study}, send personalized surveys or provide recommendations to participants`, + es: `monitorear el ${appConfig?.intro?.program_or_study}, enviar encuestas personalizadas o proporcionar recomendaciones a los participantes`, + }; + Object.entries(appConfig?.intro?.translated_text).forEach(([lang, val]: [string, any]) => { + val.raw_data_use = val.raw_data_use || default_raw_data_use[lang]; + }); + } - const templateText = useMemo(() => getTemplateText(appConfig, i18n.language), [appConfig]); + const templateText = useMemo( + () => getTemplateText(appConfig, i18n.resolvedLanguage), + [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.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.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.open-source-data')} + { + window['cordova'].InAppBrowser.open( + 'https://github.com/e-mission/e-mission-data-collection.git', + '_system', + ); + }}> + {' '} + https://github.com/e-mission/e-mission-data-collection.git{' '} + + {t('consent-text.what.open-source-analysis')} + { + window['cordova'].InAppBrowser.open( + 'https://github.com/e-mission/e-mission-server.git', + '_system', + ); + }}> + {' '} + https://github.com/e-mission/e-mission-server.git{' '} + + {t('consent-text.what.open-source-dashboard')} + { + window['cordova'].InAppBrowser.open( + 'https://github.com/e-mission/em-public-dashboard.git', + '_system', + ); + }}> + {' '} + https://github.com/e-mission/em-public-dashboard.git.{' '} + + + {'\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 - { - 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.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')} + { + window['cordova'].InAppBrowser.open( + 'https://www.nrel.gov/transportation/secure-transportation-data/', + '_system', + ); + }}> + {t('consent-text.who-sees.on-website')} + + {t('consent-text.who-sees.and-in')} + { + window['cordova'].InAppBrowser.open( + 'https://www.sciencedirect.com/science/article/pii/S2352146515002999', + '_system', + ); + }}> + {t('consent-text.who-sees.this-pub')} + + {t('consent-text.who-sees.and')} + { + window['cordova'].InAppBrowser.open( + 'https://www.nrel.gov/docs/fy18osti/70723.pdf', + '_system', + ); + }}> + {t('consent-text.who-sees.fact-sheet') + '.'} + + + {'\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})} - - ) -} + {t('consent-text.rights.header')} + {yourRightsText} + {'\n'} + + {t('consent-text.rights.destroy-data-pt1')} + {'(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 73961245a..237488ebb 100644 --- a/www/js/onboarding/ProtocolPage.tsx +++ b/www/js/onboarding/ProtocolPage.tsx @@ -4,13 +4,11 @@ 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 { setProtocolDone } from './onboardingHelper'; const ProtocolPage = () => { - const { t } = useTranslation(); const context = useContext(AppContext); const { refreshOnboardingState } = context; @@ -18,25 +16,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 658c66993..f149598a4 100644 --- a/www/js/onboarding/SaveQrPage.tsx +++ b/www/js/onboarding/SaveQrPage.tsx @@ -1,60 +1,60 @@ -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 { resetDataAndRefresh } from "../config/dynamicConfig"; -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 { 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 '../services/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; useEffect(() => { if (overallStatus == true && !registerUserDone) { - const StartPrefs = getAngularService('StartPrefs'); - StartPrefs.markConsented().then((response) => { - logDebug('permissions done, going to log in'); + logDebug('permissions done, going to log in'); + 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 CommHelper = getAngularService('CommHelper'); - const KVStore = getAngularService('KVStore'); - const EXPECTED_METHOD = "prompted-auth"; - const dbStorageObject = {"token": token}; - logDebug("about to login with token"); - return KVStore.set(EXPECTED_METHOD, dbStorageObject).then((r) => { - CommHelper.registerUser((successResult) => { - logDebug("registered user in CommHelper result " + successResult); - refreshOnboardingState(); - }, function(errorResult) { - /* if registration fails, we should take the user back to the welcome page - so they can try again with a valid token */ - displayError(errorResult, i18next.t('errors.registration-check-token')); - 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); @@ -64,30 +64,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..7ecb14fd5 100644 --- a/www/js/onboarding/StudySummary.tsx +++ b/www/js/onboarding/StudySummary.tsx @@ -1,45 +1,51 @@ -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]); + const templateText = useMemo( + () => getTemplateText(appConfig, i18n.resolvedLanguage), + [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..a7880e8e6 100644 --- a/www/js/onboarding/SurveyPage.tsx +++ b/www/js/onboarding/SurveyPage.tsx @@ -1,19 +1,22 @@ -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 = () => { +let preloadedResponsePromise: Promise; +export function preloadDemoSurveyResponse() { if (!preloadedResponsePromise) { if (!registerUserDone) { displayErrorMsg(i18next.t('errors.not-registered-cant-contact')); @@ -25,16 +28,16 @@ export const preloadDemoSurveyResponse = () => { } const SurveyPage = () => { - const { t } = useTranslation(); const { refreshOnboardingState } = useContext(AppContext); const [surveyModalVisible, setSurveyModalVisible] = useState(false); - const [prevSurveyResponse, setPrevSurveyResponse] = useState(null); + const [prevSurveyResponse, setPrevSurveyResponse] = useState(undefined); 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 + if (!surveyEndDt) return; return DateTime.fromISO(surveyEndDt).toLocaleString(DateTime.DATE_FULL); } }, [prevSurveyResponse]); @@ -60,42 +63,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 5653218d7..7ded3a208 100644 --- a/www/js/onboarding/WelcomePage.tsx +++ b/www/js/onboarding/WelcomePage.tsx @@ -1,16 +1,33 @@ -import React, { useContext, useState } from 'react'; +import React, { useContext, useState, useEffect } 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, + Icon, + 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(); @@ -20,125 +37,159 @@ const WelcomePage = () => { const [infoPopupVis, setInfoPopupVis] = useState(false); const [existingToken, setExistingToken] = useState(''); - const checkURL = function (result) { + function getCode(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 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 " + 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() { + function scanCode() { 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") ; - } + (result) => { + logDebug('scanCode: scanned ' + JSON.stringify(result)); + let code = getCode(result); + if (code != false) { + logDebug('scanCode: found code ' + code); + loginWithToken(code); + } else { + displayError(result.text, 'invalid study reference'); + } }, - function (error) { - displayError(error, "Scanning failed: "); - }); - }; + (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(''); - }); + initByUser({ token }) + .then((configUpdated) => { + if (configUpdated) { + setPasteModalVis(false); + refreshOnboardingState(); + } + }) + .catch((err) => { + displayError(err, 'Error logging in with token'); + 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, @@ -177,23 +228,21 @@ const s: any = StyleSheet.create({ }, }); - const WelcomePageButton = ({ onPress, icon, children }) => { - const { colors } = useTheme(); const { width: windowWidth } = useWindowDimensions(); return ( - - + + {children} ); -} +}; const welcomeButtonStyles: any = StyleSheet.create({ btn: ((colors): ViewStyle => ({ diff --git a/www/js/onboarding/onboardingHelper.ts b/www/js/onboarding/onboardingHelper.ts index 4a6ec202c..89e05d9e7 100644 --- a/www/js/onboarding/onboardingHelper.ts +++ b/www/js/onboarding/onboardingHelper.ts @@ -1,7 +1,9 @@ -import { DateTime } from "luxon"; -import { getAngularService } from "../angular-react-helper"; -import { getConfig, resetDataAndRefresh } from "../config/dynamicConfig"; -import { logDebug } from "../plugin/logger"; +import { DateTime } from 'luxon'; +import { getConfig, resetDataAndRefresh } from '../config/dynamicConfig'; +import { storageGet, storageSet } from '../plugin/storage'; +import { logDebug } from '../plugin/logger'; +import { EVENTS, publish } from '../customEventHandler'; +import { readConsentState, isConsented } from '../splash/startprefs'; export const INTRO_DONE_KEY = 'intro_done'; @@ -11,66 +13,79 @@ 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 + } - 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}; + isIntroDone = ${isIntroDone}; + config = ${config}; + isConsented = ${isConsented}; + saveQrDone = ${saveQrDone}`); + + return { route, opcode: config?.joined?.opcode }; + }, + ); +} async function readConsented() { - const StartPrefs = getAngularService('StartPrefs'); - return StartPrefs.readConsentState().then(StartPrefs.isConsented) as Promise; + return readConsentState().then(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 readIntroDone() { + return storageGet(INTRO_DONE_KEY).then((read_val) => Boolean(read_val)) as Promise; } export async function markIntroDone() { const currDateTime = DateTime.now().toISO(); - const KVStore = getAngularService('KVStore'); - return KVStore.set(INTRO_DONE_KEY, currDateTime); + return storageSet(INTRO_DONE_KEY, currDateTime).then(() => { + //handle "on intro" events + logDebug('intro done, publishing event'); + publish(EVENTS.INTRO_DONE_EVENT, currDateTime); + }); } diff --git a/www/js/plugin/ErrorBoundary.tsx b/www/js/plugin/ErrorBoundary.tsx new file mode 100644 index 000000000..45902b787 --- /dev/null +++ b/www/js/plugin/ErrorBoundary.tsx @@ -0,0 +1,41 @@ +// based on https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary + +import React from 'react'; +import { displayError } from './logger'; +import { View } from 'react-native'; +import { Icon } from 'react-native-paper'; + +class ErrorBoundary extends React.Component<{ children: React.ReactNode }, { hasError: boolean }> { + constructor(props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error) { + return { hasError: true }; + } + + componentDidCatch(error, info) { + displayError(error, info.componentStack); + } + + render() { + if (this.state.hasError) { + return ( + + + + ); + } + + return this.props.children; + } +} + +export const withErrorBoundary = (Component) => (props) => ( + + + +); + +export default ErrorBoundary; diff --git a/www/js/plugin/clientStats.ts b/www/js/plugin/clientStats.ts new file mode 100644 index 000000000..bdf4c1888 --- /dev/null +++ b/www/js/plugin/clientStats.ts @@ -0,0 +1,62 @@ +import { displayErrorMsg, logDebug } from './logger'; + +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', +}; + +let appVersion; +export function getAppVersion() { + if (appVersion) return Promise.resolve(appVersion); + return window['cordova']?.getAppVersion.getVersionNumber().then((version) => { + appVersion = version; + return version; + }); +} + +async function getStatsEvent(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 async function addStatReading(name: string, reading: any) { + const db = window['cordova']?.plugins?.BEMUserCache; + const event = await getStatsEvent(name, reading); + logDebug('addStatReading: adding CLIENT_TIME event: ' + JSON.stringify(event)); + if (db) return db.putMessage(CLIENT_TIME, event); + displayErrorMsg('addStatReading: db is not defined'); +} + +export async function addStatEvent(name: string) { + const db = window['cordova']?.plugins?.BEMUserCache; + const event = await getStatsEvent(name, null); + logDebug('addStatEvent: adding CLIENT_NAV_EVENT event: ' + JSON.stringify(event)); + if (db) return db.putMessage(CLIENT_NAV_EVENT, event); + displayErrorMsg('addStatEvent: db is not defined'); +} + +export async function addStatError(name: string, errorStr: string) { + const db = window['cordova']?.plugins?.BEMUserCache; + const event = await getStatsEvent(name, errorStr); + logDebug('addStatError: adding CLIENT_ERROR event: ' + JSON.stringify(event)); + if (db) return db.putMessage(CLIENT_ERROR, event); + displayErrorMsg('addStatError: db is not defined'); +} diff --git a/www/js/plugin/logger.ts b/www/js/plugin/logger.ts index c4e476de1..98e852978 100644 --- a/www/js/plugin/logger.ts +++ b/www/js/plugin/logger.ts @@ -1,37 +1,11 @@ -import angular from 'angular'; - -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) { - $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; -}]); - export const logDebug = (message: string) => - window['Logger'].log(window['Logger'].LEVEL_DEBUG, message); + window['Logger']?.log(window['Logger'].LEVEL_DEBUG, message); export const logInfo = (message: string) => - window['Logger'].log(window['Logger'].LEVEL_INFO, message); + window['Logger']?.log(window['Logger'].LEVEL_INFO, message); export const logWarn = (message: string) => - window['Logger'].log(window['Logger'].LEVEL_WARN, message); + window['Logger']?.log(window['Logger'].LEVEL_WARN, message); export function displayError(error: Error, title?: string) { const errorMsg = error.message ? error.message + '\n' + error.stack : JSON.stringify(error); @@ -40,11 +14,11 @@ 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); console.error(displayMsg); - window['Logger'].log(window['Logger'].LEVEL_ERROR, displayMsg); + window['Logger']?.log(window['Logger'].LEVEL_ERROR, displayMsg); } diff --git a/www/js/plugin/storage.js b/www/js/plugin/storage.js deleted file mode 100644 index e4d23042e..000000000 --- a/www/js/plugin/storage.js +++ /dev/null @@ -1,222 +0,0 @@ -import angular from 'angular'; - -angular.module('emission.plugin.kvstore', ['emission.plugin.logger', - 'LocalStorageModule', - 'emission.stats.clientstats']) - -.factory('KVStore', function($window, Logger, localStorageService, $ionicPopup, - $ionicPlatform, ClientStats) { - var logger = Logger; - var kvstoreJs = {} - /* - * Sets in both localstorage and native storage - * If the message is not a JSON object, wrap it in an object with the key - * "value" before storing it. - */ - var getNativePlugin = function() { - return $window.cordova.plugins.BEMUserCache; - } - - /* - * Munge plain, non-JSON objects to JSON objects before storage - */ - - var mungeValue = function(key, value) { - var store_val = value; - if (typeof value != "object") { - // Should this be {"value": value} or {key: value}? - store_val = {}; - store_val[key] = value; - } - return store_val; - } - - - kvstoreJs.set = function(key, value) { - // add checks for data type - var store_val = mungeValue(key, value); - logger.log("adding key " + key + " and value " + value + " to local storage"); - /* - * How should we deal with consistency here? Have the threads be - * independent so that there is greater chance that one will succeed, - * or the local only succeed if native succeeds. I think parallel is - * better for greater robustness. - */ - localStorageService.set(key, store_val); - return getNativePlugin().putLocalStorage(key, store_val); - } - - var getUnifiedValue = function(key) { - var ls_stored_val = localStorageService.get(key, undefined); - return getNativePlugin().getLocalStorage(key, false).then(function(uc_stored_val) { - logger.log("for key "+key+" uc_stored_val = "+JSON.stringify(uc_stored_val)+" ls_stored_val = "+JSON.stringify(ls_stored_val)); - if (angular.equals(ls_stored_val, uc_stored_val)) { - logger.log("local and native values match, already synced"); - return uc_stored_val; - } else { - // the values are different - if (ls_stored_val == null) { - console.assert(uc_stored_val != null, "uc_stored_val should be non-null"); - logger.log("for key "+key+"uc_stored_val = "+JSON.stringify(uc_stored_val)+ - " ls_stored_val = "+JSON.stringify(ls_stored_val)+ - " copying native "+key+" to local..."); - localStorageService.set(key, uc_stored_val); - return uc_stored_val; - } else if (uc_stored_val == null) { - console.assert(ls_stored_val != null); - /* - * Backwards compatibility ONLY. Right after the first - * update to this version, we may have a local value that - * is not a JSON object. In that case, we want to munge it - * before storage. Remove this after a few releases. - */ - ls_stored_val = mungeValue(key, ls_stored_val); - $ionicPopup.alert({template: "Local "+key+" found, native " - +key+" missing, writing "+key+" to native"}) - logger.log("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 getNativePlugin().putLocalStorage(key, ls_stored_val).then(function() { - // we only return the value after we have finished writing - return ls_stored_val; - }); - } - 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)); - $ionicPopup.alert({template: "Local "+key+" found, native " - +key+" found, but different, writing "+key+" to local"}) - logger.log("for key "+key+"uc_stored_val = "+JSON.stringify(uc_stored_val)+ - " ls_stored_val = "+JSON.stringify(ls_stored_val)+ - " copying native "+key+" to local..."); - localStorageService.set(key, uc_stored_val); - return uc_stored_val; - } - }); - } - - /* - * If a non-JSON object was munged for storage, unwrap it. - */ - var unmungeValue = function(key, retData) { - if((retData != null) && (angular.isDefined(retData[key]))) { - // it must have been a simple data type that we munged upfront - return retData[key]; - } else { - // it must have been an object - return retData; - } - } - - kvstoreJs.get = function(key) { - return getUnifiedValue(key).then(function(retData) { - return unmungeValue(key, retData); - }); - } - - /* - * TODO: Temporary fix for data that: - - we want to return inline instead of in a promise - - is not catastrophic if it is cleared out (e.g. walkthrough code), OR - - is used primarily for session storage so will not be cleared out - (e.g. referral code) - We can replace this with promises in a future PR if needed - - The code does copy the native value to local storage in the background, - so even if this is stripped out, it will work on retry. - */ - kvstoreJs.getDirect = function(key) { - // will run in background, we won't wait for the results - getUnifiedValue(key); - return unmungeValue(key, localStorageService.get(key)); - } - - kvstoreJs.remove = function(key) { - localStorageService.remove(key); - return getNativePlugin().removeLocalStorage(key); - } - - kvstoreJs.clearAll = function() { - localStorageService.clearAll(); - return getNativePlugin().clearAll(); - } - - /* - * Unfortunately, there is weird deletion of native - * https://github.com/e-mission/e-mission-docs/issues/930 - * So we cannot remove this if/until we switch to react native - */ - kvstoreJs.clearOnlyLocal = function() { - return localStorageService.clearAll(); - } - - kvstoreJs.clearOnlyNative = function() { - return getNativePlugin().clearAll(); - } - - let findMissing = function(fromKeys, toKeys) { - const foundKeys = []; - const missingKeys = []; - fromKeys.forEach((fk) => { - if (toKeys.includes(fk)) { - foundKeys.push(fk); - } else { - missingKeys.push(fk); - } - }); - return [foundKeys, missingKeys]; - } - - let syncAllWebAndNativeValues = function() { - console.log("STORAGE_PLUGIN: Called syncAllWebAndNativeValues "); - const syncKeys = getNativePlugin().listAllLocalStorageKeys().then((nativeKeys) => { - console.log("STORAGE_PLUGIN: native plugin returned"); - const webKeys = localStorageService.keys(); - // 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 - logger.log("STORAGE_PLUGIN: Comparing web keys "+webKeys+" with "+nativeKeys); - let [foundNative, missingNative] = findMissing(webKeys, nativeKeys); - let [foundWeb, missingWeb] = findMissing(nativeKeys, webKeys); - logger.log("STORAGE_PLUGIN: Found native keys "+foundNative+" missing native keys "+missingNative); - logger.log("STORAGE_PLUGIN: Found web keys "+foundWeb+" missing web keys "+missingWeb); - const allMissing = missingNative.concat(missingWeb); - logger.log("STORAGE_PLUGIN: Syncing all missing keys "+allMissing); - allMissing.forEach(getUnifiedValue); - if (allMissing.length != 0) { - ClientStats.addReading(ClientStats.getStatKeys().MISSING_KEYS, { - "type": "local_storage_mismatch", - "allMissingLength": allMissing.length, - "missingWebLength": missingWeb.length, - "missingNativeLength": missingNative.length, - "foundWebLength": foundWeb.length, - "foundNativeLength": foundNative.length, - "allMissing": allMissing, - }).then(Logger.log("Logged missing keys to client stats")); - } - }); - const listAllKeys = getNativePlugin().listAllUniqueKeys().then((nativeKeys) => { - logger.log("STORAGE_PLUGIN: For the record, all unique native keys are "+nativeKeys); - if (nativeKeys.length == 0) { - ClientStats.addReading(ClientStats.getStatKeys().MISSING_KEYS, { - "type": "all_native", - }).then(Logger.log("Logged all missing native keys to client stats")); - } - }); - - return Promise.all([syncKeys, listAllKeys]); - } - - $ionicPlatform.ready().then(function() { - Logger.log("STORAGE_PLUGIN: app launched, checking storage sync"); - syncAllWebAndNativeValues(); - }); - - $ionicPlatform.on("resume", function() { - Logger.log("STORAGE_PLUGIN: app has resumed, checking storage sync"); - syncAllWebAndNativeValues(); - }); - - return kvstoreJs; -}); diff --git a/www/js/plugin/storage.ts b/www/js/plugin/storage.ts new file mode 100644 index 000000000..e22bb4669 --- /dev/null +++ b/www/js/plugin/storage.ts @@ -0,0 +1,189 @@ +import { addStatReading, statKeys } from './clientStats'; +import { logDebug, logWarn } from './logger'; + +function mungeValue(key, value) { + let store_val = value; + if (typeof value != 'object') { + store_val = {}; + store_val[key] = value; + } + return store_val; +} + +/* + * If a non-JSON object was munged for storage, unwrap it. + */ +function unmungeValue(key, retData) { + if (retData?.[key]) { + // it must have been a simple data type that we munged upfront + return retData[key]; + } else { + // it must have been an object + return retData; + } +} + +function 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)); + } +} + +function localStorageGet(key: string) { + const value = localStorage.getItem(key); + if (value) { + return JSON.parse(value); + } 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. + If a value is present in both, but they are different, it copies the native value to + 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)}, + ls_stored_val = ${JSON.stringify(ls_stored_val)}.`); + + /* 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)}, + 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)}, + 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)}, + ls_stored_val = ${JSON.stringify(ls_stored_val)}. + copying native ${key} to local...`); + localStorageSet(key, uc_stored_val); + return uc_stored_val; + } + }, + ); +} + +export function storageSet(key: string, value: any) { + const storeVal = mungeValue(key, value); + /* + * How should we deal with consistency here? Have the threads be + * independent so that there is greater chance that one will succeed, + * or the local only succeed if native succeeds. I think parallel is + * better for greater robustness. + */ + localStorageSet(key, storeVal); + return window['cordova'].plugins.BEMUserCache.putLocalStorage(key, storeVal); +} + +export function storageGet(key: string) { + return getUnifiedValue(key).then((retData) => unmungeValue(key, retData)); +} + +export function storageRemove(key: string) { + localStorage.removeItem(key); + return window['cordova'].plugins.BEMUserCache.removeLocalStorage(key); +} + +export function storageClear({ local, native }: { local?: boolean; native?: boolean }) { + if (local) localStorage.clear(); + if (native) return window['cordova'].plugins.BEMUserCache.clearAll(); + return Promise.resolve(); +} + +export function storageGetDirect(key: string) { + // will run in background, we won't wait for the results + getUnifiedValue(key); + return unmungeValue(key, localStorageGet(key)); +} + +function findMissing(fromKeys: any[], toKeys: any[]) { + const foundKeys: any[] = []; + const missingKeys: any[] = []; + fromKeys.forEach((fk) => { + if (toKeys.includes(fk)) { + foundKeys.push(fk); + } else { + missingKeys.push(fk); + } + }); + return [foundKeys, missingKeys]; +} + +export function storageSyncLocalAndNative() { + logDebug('STORAGE_PLUGIN: Called syncAllWebAndNativeValues'); + const syncKeys = window['cordova'].plugins.BEMUserCache.listAllLocalStorageKeys().then( + (nativeKeys) => { + 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: native keys returned = ${JSON.stringify(nativeKeys)}; + comparing against webKeys = ${JSON.stringify(webKeys)}`); + let [foundNative, missingNative] = findMissing(webKeys, nativeKeys); + let [foundWeb, missingWeb] = findMissing(nativeKeys, webKeys); + logDebug(`STORAGE_PLUGIN: + Found native keys = ${foundNative}; Missing native keys = ${missingNative}; + 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 deleted file mode 100644 index 9a63b364d..000000000 --- a/www/js/services.js +++ /dev/null @@ -1,553 +0,0 @@ -'use strict'; - -import angular from 'angular'; - -angular.module('emission.services', ['emission.plugin.logger', - 'emission.plugin.kvstore']) - -.service('CommHelper', function($rootScope) { - var getConnectURL = function(successCallback, errorCallback) { - window.cordova.plugins.BEMConnectionSettings.getSettings( - function(settings) { - successCallback(settings.connectUrl); - }, errorCallback); - }; - - var processErrorMessages = function(errorMsg) { - if (errorMsg.includes("403")) { - errorMsg = "Error: OPcode does not exist on the server. " + errorMsg; - console.error("Error 403 found. " + errorMsg); - } - return errorMsg; - } - - this.registerUser = function(successCallback, errorCallback) { - window.cordova.plugins.BEMServerComm.getUserPersonalData("/profile/create", successCallback, errorCallback); - }; - - this.updateUser = function(updateDoc) { - return new Promise(function(resolve, reject) { - window.cordova.plugins.BEMServerComm.postUserPersonalData("/profile/update", "update_doc", updateDoc, resolve, reject); - }) - .catch(error => { - error = "While updating user, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; - - this.getUser = function() { - return new Promise(function(resolve, reject) { - window.cordova.plugins.BEMServerComm.getUserPersonalData("/profile/get", resolve, reject); - }) - .catch(error => { - error = "While getting user, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; - - this.putOne = function(key, data) { - var now = moment().unix(); - var md = { - "write_ts": now, - "read_ts": now, - "time_zone": moment.tz.guess(), - "type": "message", - "key": key, - "platform": ionic.Platform.platform() - }; - var entryToPut = { - "metadata": md, - "data": data - } - return new Promise(function(resolve, reject) { - window.cordova.plugins.BEMServerComm.postUserPersonalData("/usercache/putone", "the_entry", entryToPut, resolve, reject); - }) - .catch(error => { - error = "While putting one entry, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; - - this.getTimelineForDay = function(date) { - return new Promise(function(resolve, reject) { - var dateString = date.startOf('day').format('YYYY-MM-DD'); - window.cordova.plugins.BEMServerComm.getUserPersonalData("/timeline/getTrips/"+dateString, resolve, reject); - }) - .catch(error => { - error = "While getting timeline for day, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; - - /* - * var regConfig = {'username': ....} - * Other fields can be added easily and the server can be modified at the same time. - */ - this.habiticaRegister = function(regConfig) { - return new Promise(function(resolve, reject){ - window.cordova.plugins.BEMServerComm.postUserPersonalData("/habiticaRegister", "regConfig", regConfig, resolve, reject); - }); - }; - - /* - * Example usage: - * Get profile: - * callOpts = {'method': 'GET', 'method_url': "/api/v3/user", - 'method_args': null} - * Go to sleep: - * callOpts = {'method': 'POST', 'method_url': "/api/v3/user/sleep", - 'method_args': {'data': True}} - * Stop sleeping: - * callOpts = {'method': 'POST', 'method_url': "/api/v3/user/sleep", - 'method_args': {'data': False}} - * Get challenges for a user: - * callOpts = {'method': 'GET', 'method_url': "/api/v3/challenges/user", - 'method_args': null} - * .... - */ - - this.habiticaProxy = function(callOpts){ - return new Promise(function(resolve, reject){ - window.cordova.plugins.BEMServerComm.postUserPersonalData("/habiticaProxy", "callOpts", callOpts, resolve, reject); - }) - .catch(error => { - error = "While habitica proxy, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; - - this.getMetrics = function(timeType, metrics_query) { - return new Promise(function(resolve, reject) { - var msgFiller = function(message) { - for (var key in metrics_query) { - message[key] = metrics_query[key] - }; - }; - window.cordova.plugins.BEMServerComm.pushGetJSON("/result/metrics/"+timeType, msgFiller, resolve, reject); - }) - .catch(error => { - error = "While getting metrics, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; - - /* - * key_list = list of keys to retrieve or None for all keys - * start_time = beginning timestamp for range - * end_time = ending timestamp for rangeA - */ - this.moment2Localdate = function(momentObj) { - return { - year: momentObj.year(), - month: momentObj.month() + 1, - day: momentObj.date(), - }; - }; - - this.moment2Timestamp = function(momentObj) { - return momentObj.unix(); - } - - // time_key is typically metadata.write_ts or data.ts - this.getRawEntriesForLocalDate = function(key_list, start_ts, end_ts, - time_key = "metadata.write_ts", max_entries = undefined, trunc_method = "sample") { - return new Promise(function(resolve, reject) { - var msgFiller = function(message) { - message.key_list = key_list; - message.from_local_date = moment2Localdate(moment.unix(start_ts)); - message.to_local_date = moment2Localdate(moment.unix(end_ts)); - message.key_local_date = time_key; - if (max_entries !== undefined) { - message.max_entries = max_entries; - message.trunc_method = trunc_method; - } - console.log("About to return message "+JSON.stringify(message)); - }; - console.log("getRawEntries: about to get pushGetJSON for the timestamp"); - window.cordova.plugins.BEMServerComm.pushGetJSON("/datastreams/find_entries/local_date", msgFiller, resolve, reject); - }) - .catch(error => { - error = "While getting raw entries for local date, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; - - this.getRawEntries = function(key_list, start_ts, end_ts, - time_key = "metadata.write_ts", max_entries = undefined, trunc_method = "sample") { - return new Promise(function(resolve, reject) { - var msgFiller = function(message) { - message.key_list = key_list; - message.start_time = start_ts; - message.end_time = end_ts; - message.key_time = time_key; - if (max_entries !== undefined) { - message.max_entries = max_entries; - message.trunc_method = trunc_method; - } - console.log("About to return message "+JSON.stringify(message)); - }; - console.log("getRawEntries: about to get pushGetJSON for the timestamp"); - window.cordova.plugins.BEMServerComm.pushGetJSON("/datastreams/find_entries/timestamp", msgFiller, resolve, reject); - }) - .catch(error => { - error = "While getting raw entries, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; - - this.getPipelineCompleteTs = function() { - return new Promise(function(resolve, reject) { - console.log("getting pipeline complete timestamp"); - window.cordova.plugins.BEMServerComm.getUserPersonalData("/pipeline/get_complete_ts", resolve, reject); - }) - .catch(error => { - error = "While getting pipeline complete timestamp, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; - - this.getPipelineRangeTs = function() { - return new Promise(function(resolve, reject) { - console.log("getting pipeline range timestamps"); - window.cordova.plugins.BEMServerComm.getUserPersonalData("/pipeline/get_range_ts", resolve, reject); - }) - .catch(error => { - error = "While getting pipeline range timestamps, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; - - - // host is automatically read from $rootScope.connectUrl, which is set in app.js - this.getAggregateData = function(path, data) { - return new Promise(function(resolve, reject) { - const full_url = $rootScope.connectUrl+"/"+path; - data["aggregate"] = true - - if ($rootScope.aggregateAuth === "no_auth") { - console.log("getting aggregate data without user authentication from " - + full_url +" with arguments "+JSON.stringify(data)); - const options = { - method: 'post', - data: data, - responseType: 'json' - } - cordova.plugin.http.sendRequest(full_url, options, - function(response) { - resolve(response.data); - }, function(error) { - reject(error); - }); - } else { - console.log("getting aggregate data with user authentication from " - + full_url +" with arguments "+JSON.stringify(data)); - var msgFiller = function(message) { - return Object.assign(message, data); - }; - window.cordova.plugins.BEMServerComm.pushGetJSON("/"+path, msgFiller, resolve, reject); - } - }) - .catch(error => { - error = "While getting aggregate data, " + error; - error = processErrorMessages(error); - throw(error); - }); - }; -}) - -.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); - }) - - //function firstUpperCase(string) { - // return string[0].toUpperCase() + string.slice(1); - //}*/ - } -}) -.service('UnifiedDataLoader', function($window, CommHelper, 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 element.metadata.write_ts == value.metadata.write_ts; - }); - return firstIndexOfValue == i; - }); - }; - - // TODO: generalize to iterable of promises - var combinedPromise = function(localPromise, remotePromise, combiner) { - return new Promise(function(resolve, reject) { - var localResult = []; - var localError = null; - - var remoteResult = []; - var remoteError = null; - - 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); - } - } - }; - - 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); - }) - } - - // 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 = CommHelper.getRawEntries([key], tq.startTs, tq.endTs) - .then(function(serverResponse) { - return serverResponse.phone_data; - }); - return combinedPromise(localPromise, remotePromise, combineWithDedup); - }; - - this.getUnifiedMessagesForInterval = function(key, tq, withMetadata) { - var localPromise = $window.cordova.plugins.BEMUserCache.getMessagesForInterval(key, tq, true); - var remotePromise = CommHelper.getRawEntries([key], tq.startTs, tq.endTs) - .then(function(serverResponse) { - return serverResponse.phone_data; - }); - return combinedPromise(localPromise, remotePromise, combineWithDedup); - } -}) -.service('ControlHelper', function($window, - $ionicPopup, - CommHelper, - 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); - - 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(); - }; - - // 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(); - - 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.readAsText(file); - }, function(error) { - $ionicPopup.alert({title: "Error while downloading JSON dump", - template: error}); - reject(error); - }); - }); - }); - }); - }; - - CommHelper.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() { - return window.cordova.plugins.OPCodeAuth.getOPCode(); - }; - - this.getSettings = function() { - return window.cordova.plugins.BEMConnectionSettings.getSettings(); - }; - -}) - -.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' - }]; - - 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; - } - }; -}); diff --git a/www/js/services/commHelper.ts b/www/js/services/commHelper.ts new file mode 100644 index 000000000..ec2ee9d97 --- /dev/null +++ b/www/js/services/commHelper.ts @@ -0,0 +1,230 @@ +import { DateTime } from 'luxon'; +import { displayError, logDebug } from '../plugin/logger'; +import { ServerConnConfig } from '../types/appConfigTypes'; +import { TimestampRange } from '../types/diaryTypes'; + +/** + * @param url URL endpoint for the request + * @param fetchOpts (optional) options for the fetch request. If 'cache' is set to 'reload', the cache will be ignored + * @returns Promise of the fetched response (as text) or cached text from local storage + */ +export async function fetchUrlCached(url: string, fetchOpts?: RequestInit) { + const stored = localStorage.getItem(url); + if (stored && fetchOpts?.cache != 'reload') { + logDebug(`fetchUrlCached: found cached data for url ${url}, returning`); + return Promise.resolve(stored); + } + try { + logDebug(`fetchUrlCached: cache had ${stored} for url ${url}, not using; fetching`); + const response = await fetch(url, fetchOpts); + const text = await response.text(); + localStorage.setItem(url, text); + logDebug(`fetchUrlCached: fetched data for url ${url}, returning`); + return text; + } catch (e) { + displayError(e, `While fetching ${url}`); + } +} + +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; + message.start_time = start_ts; + message.end_time = end_ts; + message.key_time = time_key; + if (max_entries !== undefined) { + message.max_entries = max_entries; + 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) => { + error = `While getting raw entries, ${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', +) { + return new Promise((rs, rj) => { + const msgFiller = (message) => { + message.key_list = key_list; + message.from_local_date = DateTime.fromSeconds(start_ts).toObject(); + message.to_local_date = DateTime.fromSeconds(end_ts).toObject(); + message.key_local_date = time_key; + if (max_entries !== undefined) { + message.max_entries = max_entries; + 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/local_date', + msgFiller, + rs, + rj, + ); + }).catch((error) => { + error = 'While getting raw entries for local date, ' + error; + throw error; + }); +} + +export function getPipelineRangeTs(): Promise { + return new Promise((rs: (rangeTs: TimestampRange) => void, rj) => { + 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; + }); +} + +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) => { + error = `While getting pipeline complete timestamp, ${error}`; + throw error; + }); +} + +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) => { + error = `While getting metrics, ${error}`; + throw error; + }); +} + +export function getAggregateData(path: string, query, serverConnConfig: ServerConnConfig) { + return new Promise((rs, rj) => { + const fullUrl = `${serverConnConfig.connectUrl}/${path}`; + query['aggregate'] = true; + + if (serverConnConfig.aggregate_call_auth == 'no_auth') { + logDebug(`getting aggregate data without user authentication from ${fullUrl} + with arguments ${JSON.stringify(query)}`); + const options = { + method: 'post', + data: query, + responseType: 'json', + }; + window['cordova'].plugin.http.sendRequest( + fullUrl, + options, + (response) => { + rs(response.data); + }, + (error) => { + rj(error); + }, + ); + } else { + logDebug(`getting aggregate data with user authentication from ${fullUrl} + with arguments ${JSON.stringify(query)}`); + const msgFiller = (message) => Object.assign(message, query); + window['cordova'].plugins.BEMServerComm.pushGetJSON(`/${path}`, msgFiller, rs, rj); + } + }).catch((error) => { + error = `While getting aggregate data, ${error}`; + throw error; + }); +} + +export function registerUser() { + return new Promise((rs, rj) => { + window['cordova'].plugins.BEMServerComm.getUserPersonalData('/profile/create', rs, rj); + }).catch((error) => { + error = `While registering user, ${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) => { + error = `While updating user, ${error}`; + throw error; + }); +} + +export function getUser() { + return new Promise((rs, rj) => { + window['cordova'].plugins.BEMServerComm.getUserPersonalData('/profile/get', rs, rj); + }).catch((error) => { + error = `While getting user, ${error}`; + throw error; + }); +} + +export function putOne(key, data) { + const nowTs = DateTime.now().toUnixInteger(); + const metadata = { + write_ts: nowTs, + read_ts: nowTs, + time_zone: DateTime.local().zoneName, + 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; + }); +} diff --git a/www/js/services/controlHelper.ts b/www/js/services/controlHelper.ts new file mode 100644 index 000000000..1a9016557 --- /dev/null +++ b/www/js/services/controlHelper.ts @@ -0,0 +1,146 @@ +import { DateTime } from 'luxon'; + +import { getRawEntries } from './commHelper'; +import { logInfo, displayError, logDebug, logWarn } from '../plugin/logger'; +import { FsWindow } from '../types/fileShareTypes'; +import { ServerResponse } from '../types/serverData'; +import i18next from '../i18nextInit'; + +declare let window: FsWindow; + +export function getMyDataHelpers(fileName: string, startTimeString: string, endTimeString: string) { + function localWriteFile(result: ServerResponse) { + const resultList = result.phone_data; + return new Promise((resolve, reject) => { + window['resolveLocalFileSystemURL'](window['cordova'].file.cacheDirectory, (fs) => { + fs.filesystem.root.getFile(fileName, { create: true, exclusive: false }, (fileEntry) => { + logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`); + fileEntry.createWriter((fileWriter) => { + fileWriter.onwriteend = () => { + logDebug('Successful file write...'); + resolve(); + }; + fileWriter.onerror = (e) => { + logDebug(`Failed file write: ${e.toString()}`); + reject(); + }; + logDebug(`fileWriter is: ${JSON.stringify(fileWriter.onwriteend, null, 2)}`); + // if data object is not passed in, create a new blob instead. + const dataObj = new Blob([JSON.stringify(resultList, null, 2)], { + type: 'application/json', + }); + fileWriter.write(dataObj); + }); + }); + }); + }); + } + + function localShareData() { + return new Promise((resolve, reject) => { + window['resolveLocalFileSystemURL'](window['cordova'].file.cacheDirectory, (fs) => { + fs.filesystem.root.getFile(fileName, null, (fileEntry) => { + logDebug(`fileEntry ${fileEntry.nativeURL} is file? ${fileEntry.isFile.toString()}`); + fileEntry.file( + (file) => { + const reader = new FileReader(); + + reader.onloadend = () => { + const readResult = this.result as string; + logDebug(`Successfull file read with ${readResult.length} characters`); + const dataArray = JSON.parse(readResult); + logDebug(`Successfully read resultList of size ${dataArray.length}`); + let attachFile = fileEntry.nativeURL; + const shareObj = { + files: [attachFile], + message: i18next.t( + 'email-service.email-data.body-data-consists-of-list-of-entries', + ), + subject: i18next.t('email-service.email-data.subject-data-dump-from-to', { + start: startTimeString, + end: endTimeString, + }), + }; + window['plugins'].socialsharing.shareWithOptions( + shareObj, + (result) => { + logDebug(`Share Completed? ${result.completed}`); // On Android, most likely returns false + logDebug(`Shared to app: ${result.app}`); + resolve(); + }, + (msg) => { + logDebug(`Sharing failed with message ${msg}`); + }, + ); + }; + reader.readAsText(file); + }, + (error) => { + displayError(error, 'Error while downloading JSON dump'); + reject(error); + }, + ); + }); + }); + }); + } + + // window['cordova'].file.cacheDirectory is not guaranteed to free up memory, + // so it's good practice to remove the file right after it's used! + function localClearData() { + return new Promise((resolve, reject) => { + window['resolveLocalFileSystemURL'](window['cordova'].file.cacheDirectory, (fs) => { + fs.filesystem.root.getFile(fileName, null, (fileEntry) => { + fileEntry.remove( + () => { + logDebug(`Successfully cleaned up file ${fileName}`); + resolve(); + }, + (err) => { + logWarn(`Error deleting ${fileName} : ${err}`); + reject(err); + }, + ); + }); + }); + }); + } + + return { + writeFile: localWriteFile, + shareData: localShareData, + clearData: localClearData, + }; +} + +/** + * getMyData fetches timeline data for a given day, and then gives the user a prompt to share the data + * @param timeStamp initial timestamp of the timeline to be fetched. + */ +export function getMyData(timeStamp: Date) { + // We are only retrieving data for a single day to avoid + // running out of memory on the phone + const endTime = DateTime.fromJSDate(timeStamp); + const startTime = endTime.startOf('day'); + const startTimeString = startTime.toFormat("yyyy'-'MM'-'dd"); + const endTimeString = endTime.toFormat("yyyy'-'MM'-'dd"); + + const dumpFile = startTimeString + '.' + endTimeString + '.timeline'; + alert(`Going to retrieve data to ${dumpFile}`); + + const getDataMethods = getMyDataHelpers(dumpFile, startTimeString, endTimeString); + + getRawEntries(null, startTime.toUnixInteger(), endTime.toUnixInteger()) + .then(getDataMethods.writeFile) + .then(getDataMethods.shareData) + .then(getDataMethods.clearData) + .then(() => { + logInfo('Share queued successfully'); + }) + .catch((error) => { + displayError(error, 'Error sharing JSON dump'); + }); +} + +export const fetchOPCode = () => window['cordova'].plugins.OPCodeAuth.getOPCode(); +export const getSettings = () => window['cordova'].plugins.BEMConnectionSettings.getSettings(); diff --git a/www/js/services/unifiedDataLoader.ts b/www/js/services/unifiedDataLoader.ts new file mode 100644 index 000000000..97f20f8bf --- /dev/null +++ b/www/js/services/unifiedDataLoader.ts @@ -0,0 +1,68 @@ +import { getRawEntries } from './commHelper'; +import { ServerResponse, BEMData, TimeQuery } from '../types/serverData'; + +/** + * removeDup is a helper function for combinedPromises + * @param list An array of values from a BEMUserCache promise + * @returns an array with duplicate values removed + */ +export function removeDup(list: Array>) { + return list.filter((value, i, array) => { + const firstIndexOfValue = array.findIndex( + (element) => element.metadata.write_ts == value.metadata.write_ts, + ); + return firstIndexOfValue == i; + }); +} + +export function combinedPromises( + promiseList: Array>, + filter: (list: Array) => Array, +) { + if (promiseList.length === 0) { + throw new RangeError('combinedPromises needs input array.length >= 1'); + } + return new Promise((resolve, reject) => { + Promise.allSettled(promiseList).then( + (results) => { + let allRej = true; + const values: Promise[] = []; + const rejections: any[] = []; + results.forEach((item) => { + if (item.status === 'fulfilled') { + if (allRej) allRej = false; + if (item.value.length != 0) values.push(item.value); + } else rejections.push(item.reason); + }); + if (allRej) reject(rejections); + else resolve(filter(values.flat(1))); + }, + (err) => { + reject(err); + }, + ); + }); +} + +/** + * getUnifiedDataForInterval is a generalized method to fetch data by its timestamps + * @param key string corresponding to a data entry + * @param tq an object that contains interval start and end times + * @param localGetMethod a BEMUserCache method that fetches certain data via a promise + * @returns A promise that evaluates to the all values found within the queried data + */ +export function getUnifiedDataForInterval( + key: string, + tq: TimeQuery, + localGetMethod: (key: string, tq: TimeQuery, flag: boolean) => Promise, +) { + const test = true; + const getPromise = localGetMethod(key, tq, test); + const remotePromise = getRawEntries([key], tq.startTs, tq.endTs).then( + (serverResponse: ServerResponse) => { + return serverResponse.phone_data; + }, + ); + const promiseList = [getPromise, remotePromise]; + return combinedPromises(promiseList, removeDup); +} 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; -}); diff --git a/www/js/splash/customURL.ts b/www/js/splash/customURL.ts new file mode 100644 index 000000000..21625ac29 --- /dev/null +++ b/www/js/splash/customURL.ts @@ -0,0 +1,24 @@ +import { displayError } from '../plugin/logger'; + +type UrlComponents = { + [key: string]: string; +}; + +export function onLaunchCustomURL( + rawUrl: string, + handler: (url: string, urlComponents: UrlComponents) => void, +) { + 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 (err) { + displayError(err, 'onLaunchCustomURL: not a valid URL'); + } +} diff --git a/www/js/splash/localnotify.js b/www/js/splash/localnotify.js deleted file mode 100644 index 6a4241f2c..000000000 --- a/www/js/splash/localnotify.js +++ /dev/null @@ -1,109 +0,0 @@ -/* - * We think that a common pattern is to generate a prompt to notify the user - * about something and then to re-route them to the appropriate tab. An - * existing example is the notification prompt. So let's write a standard - * factory to make that easier. - */ - -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) { - 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; - } - - 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; - } - }, { - 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.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(); - }); - - return localNotify; -}); diff --git a/www/js/splash/notifScheduler.js b/www/js/splash/notifScheduler.js deleted file mode 100644 index 821b6fb09..000000000 --- a/www/js/splash/notifScheduler.js +++ /dev/null @@ -1,271 +0,0 @@ -'use strict'; - -import angular from 'angular'; -import { getConfig } from '../config/dynamicConfig'; - -angular.module('emission.splash.notifscheduler', - ['emission.services', - 'emission.plugin.logger', - 'emission.stats.clientstats']) - -.factory('NotificationScheduler', function($http, $window, $ionicPlatform, - ClientStats, CommHelper, Logger) { - - const scheduler = {}; - let _config; - let scheduledPromise = new Promise((rs) => rs()); - let isScheduling = false; - - // like python range() - function range(start, stop, step) { - 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); - } - } - 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; - } - } - return true; - } - - /* remove notif actions as they do not work, can restore post routing migration */ - // const setUpActions = () => { - // const action = { - // id: 'action', - // title: 'Change Time', - // launch: true - // }; - // return new Promise((rs) => { - // cordova.plugins.notification.local.addActions('reminder-actions', [action], rs); - // }); - // } - - 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}`); - }); - } - - //new method to fetch notifications - 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); - }) - } - }) - } - - //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); - }); - }) - } - - // 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 - }); - }); - }); - } - - // 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); - - 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, - }; - } - - /* EXAMPLE VALUES - present in user profile object - reminder_assignment: 'passive', - reminder_join_date: '2023-05-09', - reminder_time_of_day: '21:00', - */ - - scheduler.getReminderPrefs = async () => { - const user = await CommHelper.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 CommHelper.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; - ClientStats.addReading(ClientStats.getStatKeys().REMINDER_PREFS, { - reminder_assignment, - reminder_join_date, - reminder_time_of_day - }).then(Logger.log("Added reminder prefs to client stats")); - }); - - 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(); - }); - - return scheduler; -}); diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts new file mode 100644 index 000000000..10d20b18b --- /dev/null +++ b/www/js/splash/notifScheduler.ts @@ -0,0 +1,297 @@ +import { addStatReading, statKeys } from '../plugin/clientStats'; +import { getUser, updateUser } from '../services/commHelper'; +import { displayErrorMsg, logDebug } from '../plugin/logger'; +import { DateTime } from 'luxon'; +import i18next from 'i18next'; +import { ReminderSchemesConfig } from '../types/appConfigTypes'; + +// like python range() +function range(start, stop, step) { + let a = [start], + b = start; + while (b < stop) a.push((b += step || 1)); + return a; +} + +// returns an array of DateTime objects, for all times that notifications should be sent +function calcNotifTimes(scheme, dayZeroDate, timeOfDay): DateTime[] { + const notifTimes: DateTime[] = []; + 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 = DateTime.fromFormat(dayZeroDate, 'yyyy-MM-dd') + .plus({ days: d }) + .toFormat('yyyy-MM-dd'); + const notifTime = DateTime.fromFormat(date + ' ' + timeOfDay, 'yyyy-MM-dd HH:mm'); + if (notifTime.isValid) { + notifTimes.push(notifTime); + } else { + displayErrorMsg('Cannot schedule notifs with invalid time of day: ' + timeOfDay); + } + } + } + return notifTimes; +} + +// returns true if all expected times are already scheduled +function areAlreadyScheduled(notifs: any[], expectedTimes: DateTime[]) { + for (const t of expectedTimes) { + if (!notifs.some((n) => DateTime.fromJSDate(n.trigger.at).equals(t))) { + return false; + } + } + return true; +} + +/* remove notif actions as they do not work, can restore post routing migration */ +// const setUpActions = () => { +// const action = { +// id: 'action', +// title: 'Change Time', +// launch: true +// }; +// return new Promise((rs) => { +// cordova.plugins.notification.local.addActions('reminder-actions', [action], rs); +// }); +// } +function debugGetScheduled(prefix) { + window['cordova'].plugins.notification.local.getScheduled((notifs) => { + if (!notifs?.length) return logDebug(`${prefix}, there are no scheduled notifications`); + const time = DateTime.fromJSDate(notifs[0].trigger.at).toFormat('HH:mm'); + //was in plugin, changed to scheduler + let scheduledNotifs: { key: string; val: string }[] = []; + scheduledNotifs = notifs.map((n) => { + const date = DateTime.fromJSDate(n.trigger.at).toFormat('DDD'); + const time = DateTime.fromJSDate(n.trigger.at).toFormat('t'); + return { + key: date, + val: time, + }; + }); + //have the list of scheduled show up in this log + logDebug(`${prefix}, there are ${notifs.length} scheduled notifications at ${time}; + first is ${scheduledNotifs[0].key} at ${scheduledNotifs[0].val}`); + }); +} + +//new method to fetch notifications +export function getScheduledNotifs(isScheduling: boolean, scheduledPromise: Promise) { + 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) { + logDebug('requesting fetch while still actively scheduling, waiting on scheduledPromise'); + scheduledPromise.then(() => { + getNotifs().then((notifs) => { + resolve(notifs); + }); + }); + } else { + getNotifs().then((notifs) => { + resolve(notifs); + }); + } + }); +} + +type ScheduledNotif = { key: string; val: string }; +//get scheduled notifications from cordova plugin and format them +function getNotifs() { + return new Promise((resolve, reject) => { + window['cordova'].plugins.notification.local.getScheduled((notifs: any[]) => { + if (!notifs?.length) { + logDebug('there are no notifications'); + resolve([]); //if none, return empty array + } else { + // some empty objects slip through, remove them from notifs + notifs = removeEmptyObjects(notifs); + } + + const notifSubset = notifs.slice(0, 5); //prevent near-infinite listing + let scheduledNotifs: ScheduledNotif[] = []; + scheduledNotifs = notifSubset.map((n) => { + const time: string = DateTime.fromJSDate(n.trigger.at).toFormat('t'); + const date: string = DateTime.fromJSDate(n.trigger.at).toFormat('DDD'); + return { + key: date, + val: time, + }; + }); + resolve(scheduledNotifs); + }); + }); +} + +// schedules the notifications using the cordova plugin +function scheduleNotifs(scheme, notifTimes: DateTime[], setIsScheduling: Function) { + return new Promise((rs) => { + setIsScheduling(true); + const localeCode = i18next.resolvedLanguage || 'en'; + const nots = notifTimes.map((n) => { + const nDate = n.toJSDate(); + const seconds = nDate.getTime() / 1000; // the id must be in seconds, otherwise the sorting won't work + 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 + // } + // } + // } + }; + }); + nots.sort((a, b) => b.id - a.id); // sort notifications by id (time) + window['cordova'].plugins.notification.local.cancelAll(() => { + debugGetScheduled('After cancelling'); + window['cordova'].plugins.notification.local.schedule(nots, () => { + debugGetScheduled('After scheduling'); + setIsScheduling(false); + rs(); //scheduling promise resolved here + }); + }); + }); +} + +const removeEmptyObjects = (list: any[]): any[] => list.filter((n) => Object.keys(n).length !== 0); + +// determines when notifications are needed, and schedules them if not already scheduled +export async function updateScheduledNotifs( + reminderSchemes: ReminderSchemesConfig, + isScheduling: boolean, + setIsScheduling: Function, + scheduledPromise: Promise, +): Promise { + const { reminder_assignment, reminder_join_date, reminder_time_of_day } = await getReminderPrefs( + reminderSchemes, + isScheduling, + setIsScheduling, + scheduledPromise, + ); + const scheme = reminderSchemes[reminder_assignment]; + if (scheme === undefined) { + logDebug('Error: Reminder scheme not found'); + return; + } + const notifTimes = calcNotifTimes(scheme, reminder_join_date, reminder_time_of_day); + return new Promise((resolve, reject) => { + window['cordova'].plugins.notification.local.getScheduled((notifs: any[]) => { + // some empty objects slip through, remove them from notifs + notifs = removeEmptyObjects(notifs); + if (areAlreadyScheduled(notifs, notifTimes)) { + logDebug('Already scheduled, not scheduling again'); + resolve(); + } 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) { + logDebug('ERROR: Already scheduling notifications, not scheduling again'); + resolve(); + } else { + scheduledPromise = scheduleNotifs(scheme, notifTimes, setIsScheduling); + //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 +*/ +function initReminderPrefs(reminderSchemes: object): object { + // randomly assign from the schemes listed in config + const schemes = Object.keys(reminderSchemes); + const randAssignment: string = schemes[Math.floor(Math.random() * schemes.length)]; + const todayDate: string = DateTime.local().toFormat('yyyy-MM-dd'); + const defaultTime: string = 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', + reminder_join_date: '2023-05-09', + reminder_time_of_day: '21:00', +*/ + +interface User { + reminder_assignment: string; + reminder_join_date: string; + reminder_time_of_day: string; +} + +export async function getReminderPrefs( + reminderSchemes: ReminderSchemesConfig, + isScheduling: boolean, + setIsScheduling: Function, + scheduledPromise: Promise, +): Promise { + const userPromise = getUser(); + const user = (await userPromise) as User; + if (user?.reminder_assignment && user?.reminder_join_date && user?.reminder_time_of_day) { + logDebug('User already has reminder prefs, returning them: ' + JSON.stringify(user)); + return user; + } + // if no prefs, user just joined, so initialize them + logDebug('User just joined, Initializing reminder prefs'); + const initPrefs = initReminderPrefs(reminderSchemes); + logDebug('Initialized reminder prefs: ' + JSON.stringify(initPrefs)); + await setReminderPrefs( + initPrefs, + reminderSchemes, + isScheduling, + setIsScheduling, + scheduledPromise, + ); + return { ...user, ...initPrefs }; // user profile + the new prefs +} + +export async function setReminderPrefs( + newPrefs: object, + reminderSchemes: ReminderSchemesConfig, + isScheduling: boolean, + setIsScheduling: Function, + scheduledPromise: Promise, +): Promise { + await updateUser(newPrefs); + const updatePromise = new Promise((resolve, reject) => { + //enforcing update before moving on + updateScheduledNotifs(reminderSchemes, isScheduling, setIsScheduling, scheduledPromise).then( + () => { + resolve(); + }, + ); + }); + // record the new prefs in client stats + getReminderPrefs(reminderSchemes, isScheduling, setIsScheduling, scheduledPromise).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(logDebug('Added reminder prefs to client stats')); + }, + ); + return updatePromise; +} diff --git a/www/js/splash/pushNotifySettings.ts b/www/js/splash/pushNotifySettings.ts new file mode 100644 index 000000000..5613867fb --- /dev/null +++ b/www/js/splash/pushNotifySettings.ts @@ -0,0 +1,244 @@ +/* + * 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 + * re-emits a CLOUD_NOTIFICATION_EVENT that other modules can listen to. + * + * Other modules, such as the survey code, and the remotenotify module, listen + * to these CLOUD_NOTIFICATION_EVENTs and handle them through launching + * surveys, displaying popups, etc. + * + * This allows us to decouple the push handling logic from push notification + * interface. Note that the local notification is not currently decoupled since + * it only supports redirection to a specific app page. If the local + * notification handling gets more complex, we should consider decoupling it as well. + */ + +import { updateUser } from '../services/commHelper'; +import { logDebug, displayError, logWarn } from '../plugin/logger'; +import { publish, subscribe, EVENTS } from '../customEventHandler'; +import { isConsented, readConsentState } from './startprefs'; +import { readIntroDone } from '../onboarding/onboardingHelper'; +import { AlertManager } from '../components/AlertBar'; + +let push; + +/** + * @function initializes the PushNotification in window, + * assigns on 'notification' functionality + */ +function startupInit() { + 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', (data) => { + if (window['cordova'].platformId == 'ios') { + // Parse the iOS values that are returned as strings + if (data && data.additionalData) { + if (data.additionalData.payload) { + data.additionalData.payload = JSON.parse(data.additionalData.payload); + } + if (data.additionalData.data && typeof data.additionalData.data == 'string') { + data.additionalData.data = JSON.parse(data.additionalData.data); + } else { + logDebug('additionalData is already an object, no need to parse it'); + } + } else { + logDebug('No additional data defined, nothing to parse'); + } + } + publish(EVENTS.CLOUD_NOTIFICATION_EVENT, data); + }); +} + +/** + * @function registers notifications and handles result + * @returns Promise for initialization logic, + * resolves on registration with token + * rejects on error with error + */ +function registerPromise() { + return new Promise<{ token: string; type: string }>((resolve, reject) => { + startupInit(); + push.on('registration', (data) => { + logDebug('Got registration ' + data); + resolve({ + token: data.registrationId, + type: data.registrationType, + }); + }); + push.on('error', (error) => { + logWarn('Got push error ' + error); + reject(error); + }); + logDebug('push notify = ' + push); + }); +} + +/** + * @function registers for notifications and updates user + * currently called on reconsent and on intro done + */ +function registerPush() { + registerPromise() + .then((t) => { + logDebug('Token = ' + JSON.stringify(t)); + return window['cordova'].plugins.BEMServerSync.getConfig() + .then( + (config) => { + return config.sync_interval; + }, + (error) => { + logWarn('Got error ' + error + ' while reading config, returning default = 3600'); + return 3600; + }, + ) + .then((sync_interval) => { + updateUser({ + device_token: t['token'], + curr_platform: window['cordova'].platformId, + curr_sync_interval: sync_interval, + }); + return t; + }); + }) + .then((t) => { + logDebug('Finished saving token = ' + JSON.stringify(t.token)); + }) + .catch((error) => { + if (error.message.includes('remote notifications are not supported in the simulator')) { + AlertManager.addMessage({ + text: 'Error in registering push notifications: ' + error.message, + }); + } else { + displayError(error, 'Error in registering push notifications'); + } + }); +} + +/** + * @function handles silent push notifications + * works with BEMDataCollection plugin + * @param data from the notification + * @returns early if platform is not ios + */ +function redirectSilentPush(event, data) { + logDebug('Found silent push notification, for platform ' + window['cordova'].platformId); + if (window['cordova'].platformId != 'ios') { + logDebug('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; + } + logDebug('Platform is ios, calling handleSilentPush on DataCollection'); + const notId = data.additionalData.payload.notId; + const finishErrFn = (error) => { + logDebug('in push.finish, error = ' + error); + }; + + window['cordova'].plugins.BEMDataCollection.getConfig() + .then((config) => { + if (config.ios_use_remote_push_for_sync) { + window['cordova'].plugins.BEMDataCollection.handleSilentPush().then(() => { + logDebug('silent push finished successfully, calling push.finish'); + showDebugLocalNotification('silent push finished, calling push.finish'); + push.finish(() => {}, finishErrFn, notId); + }); + } else { + logDebug('Using background fetch for sync, no need to redirect push'); + push.finish(() => {}, finishErrFn, notId); + } + }) + .catch((error) => { + push.finish(() => {}, finishErrFn, notId); + displayError(error, 'Error while redirecting silent push'); + }); +} + +/** + * @function shows debug notifications if simulating user interaction + * @param message string to display in the degug notif + */ +function showDebugLocalNotification(message) { + window['cordova'].plugins.BEMDataCollection.getConfig().then((config) => { + if (config.simulate_user_interaction) { + window['cordova'].plugins.notification.local.schedule({ + id: 1, + title: 'Debug javascript notification', + text: message, + actions: [], + category: 'SIGN_IN_TO_CLASS', + }); + } + }); +} + +/** + * @function handles pushNotification intitially + * @param event that called this function + * @param data from the notification + */ +function onCloudEvent(event, data) { + logDebug('data = ' + JSON.stringify(data)); + if (data.additionalData['content-available'] == 1) { + redirectSilentPush(event, data); + } // else no need to call finish +} + +/** + * @function registers push on reconsent + * @param event that called this function + * @param data data from the conesnt event + */ +function onConsentEvent(event, data) { + logDebug(`got consented event ${JSON.stringify(event['name'])} + with data ${JSON.stringify(data)}`); + readIntroDone().then((isIntroDone) => { + if (isIntroDone) { + logDebug('intro is done -> reconsent situation, we already have a token -> register'); + registerPush(); + } + }); +} + +/** + * @function registers push after intro received + * @param event that called this function + * @param data from the event + */ +function onIntroEvent(event, data) { + logDebug(`intro is done -> original consent situation, + we should have a token by now -> register`); + registerPush(); +} + +/** + * startup code - + * @function registers push if consented, subscribes event listeners for local handline + */ +export function initPushNotify() { + readConsentState() + .then(isConsented) + .then((consentState) => { + if (consentState == true) { + logDebug('already consented, signing up for remote push'); + registerPush(); + } else { + logDebug('no consent yet, waiting to sign up for remote push'); + } + }); + + subscribe(EVENTS.CLOUD_NOTIFICATION_EVENT, (event) => onCloudEvent(event, event.detail)); + subscribe(EVENTS.CONSENTED_EVENT, (event) => onConsentEvent(event, event.detail)); + subscribe(EVENTS.INTRO_DONE_EVENT, (event) => onIntroEvent(event, event.detail)); + + logDebug('pushnotify startup done'); +} diff --git a/www/js/splash/pushnotify.js b/www/js/splash/pushnotify.js deleted file mode 100644 index 66c70f45c..000000000 --- a/www/js/splash/pushnotify.js +++ /dev/null @@ -1,189 +0,0 @@ -/* - * 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 - * re-emits a CLOUD_NOTIFICATION_EVENT that other modules can listen to. - * - * Other modules, such as the survey code, and the remotenotify module, listen - * to these CLOUD_NOTIFICATION_EVENTs and handle them through launching - * surveys, displaying popups, etc. - * - * This allows us to decouple the push handling logic from push notification - * interface. Note that the local notification is not currently decoupled since - * it only supports redirection to a specific app page. If the local - * notification handling gets more complex, we should consider decoupling it as well. - */ - -import angular from 'angular'; - -angular.module('emission.splash.pushnotify', ['emission.plugin.logger', - 'emission.services', - 'emission.splash.startprefs']) -.factory('PushNotify', function($window, $state, $rootScope, $ionicPlatform, - $ionicPopup, Logger, CommHelper, StartPrefs) { - - 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')) { - // 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"); - } - } else { - Logger.log("No additional data defined, nothing to parse"); - } - } - $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.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) { - CommHelper.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()); - if (!$ionicPlatform.is('ios')) { - 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"); - var notId = data.additionalData.payload.notId; - 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.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; - StartPrefs.readConsentState() - .then(StartPrefs.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"); - }); - - $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()) { - 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(); - }); - - return pushnotify; -}); diff --git a/www/js/splash/referral.js b/www/js/splash/referral.js deleted file mode 100644 index 9e4707200..000000000 --- a/www/js/splash/referral.js +++ /dev/null @@ -1,43 +0,0 @@ -import angular from 'angular'; - -angular.module('emission.splash.referral', ['emission.plugin.kvstore']) - -.factory('ReferralHandler', function($window, KVStore) { - var referralHandler = {}; - - var REFERRAL_NAVIGATION_KEY = 'referral_navigation'; - var REFERRED_KEY = 'referred'; - var REFERRED_GROUP_ID = 'referred_group_id'; - var REFERRED_USER_ID = 'referred_user_id'; - - referralHandler.getReferralNavigation = function() { - const toReturn = KVStore.getDirect(REFERRAL_NAVIGATION_KEY); - KVStore.remove(REFERRAL_NAVIGATION_KEY); - return toReturn; - } - - referralHandler.setupGroupReferral = function(kvList) { - KVStore.set(REFERRED_KEY, true); - KVStore.set(REFERRED_GROUP_ID, kvList['groupid']); - KVStore.set(REFERRED_USER_ID, kvList['userid']); - KVStore.set(REFERRAL_NAVIGATION_KEY, 'goals'); - }; - - referralHandler.clearGroupReferral = function(kvList) { - KVStore.remove(REFERRED_KEY); - KVStore.remove(REFERRED_GROUP_ID); - KVStore.remove(REFERRED_USER_ID); - KVStore.remove(REFERRAL_NAVIGATION_KEY); - }; - - referralHandler.getReferralParams = function(kvList) { - return [KVStore.getDirect(REFERRED_GROUP_ID), - KVStore.getDirect(REFERRED_USER_ID)]; - } - - referralHandler.hasPendingRegistration = function() { - return KVStore.getDirect(REFERRED_KEY) - }; - - return referralHandler; -}); diff --git a/www/js/splash/remoteNotifyHandler.ts b/www/js/splash/remoteNotifyHandler.ts new file mode 100644 index 000000000..098625ee2 --- /dev/null +++ b/www/js/splash/remoteNotifyHandler.ts @@ -0,0 +1,70 @@ +/* + * 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 + * assumes that another module (currently `pushnotify`) deals with the plugin + * interface and emits a CLOUD_NOTIFICATION_EVENT when a push notification is + * received. + * + * This allows us to decouple the push handling logic from push notification + * interface. Note that the local notification is not currently decoupled since + * it only supports redirection to a specific app page. If the local + * notification handling gets more complex, we should consider decoupling it as well. + */ +import { EVENTS, subscribe } from '../customEventHandler'; +import { addStatEvent, statKeys } from '../plugin/clientStats'; +import { displayErrorMsg, logDebug } from '../plugin/logger'; + +const options = 'location=yes,clearcache=no,toolbar=yes,hideurlbar=yes'; + +/* +TODO: Potentially unify with the survey URL loading +*/ +/** + * @function launches a webpage + * @param url to open in the browser + */ +const launchWebpage = (url) => window['cordova'].InAppBrowser.open(url, '_blank', options); + +/** + * @description callback for cloud notification event + * @param event that triggered this call + */ +function onCloudNotifEvent(event) { + const data = event.detail; + addStatEvent(statKeys.NOTIFICATION_OPEN); + logDebug('data = ' + JSON.stringify(data)); + if ( + data.additionalData && + data.additionalData.payload && + data.additionalData.payload.alert_type + ) { + if (data.additionalData.payload.alert_type == 'website') { + const webpageSpec = data.additionalData.payload.spec; + if (webpageSpec?.url?.startsWith('https://')) { + launchWebpage(webpageSpec.url); + } else { + displayErrorMsg( + JSON.stringify(webpageSpec), + 'webpage was not specified correctly. spec is ', + ); + } + } + if (data.additionalData.payload.alert_type == 'popup') { + const popupSpec = data.additionalData.payload.spec; + if (popupSpec?.title && popupSpec?.text) { + /* TODO: replace popup with something with better UI */ + window.alert(popupSpec.title + ' ' + popupSpec.text); + } else { + displayErrorMsg(JSON.stringify(popupSpec), 'popup was not specified correctly. spec is '); + } + } + } +} + +/** + * @function initializes the remote notification handling + * subscribes to cloud notification event + */ +export function initRemoteNotifyHandler() { + subscribe(EVENTS.CLOUD_NOTIFICATION_EVENT, onCloudNotifEvent); +} diff --git a/www/js/splash/remotenotify.js b/www/js/splash/remotenotify.js deleted file mode 100644 index 2074da5b8..000000000 --- a/www/js/splash/remotenotify.js +++ /dev/null @@ -1,79 +0,0 @@ -/* - * 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 - * assumes that another module (currently `pushnotify`) deals with the plugin - * interface and emits a CLOUD_NOTIFICATION_EVENT when a push notification is - * received. - * - * This allows us to decouple the push handling logic from push notification - * interface. Note that the local notification is not currently decoupled since - * it only supports redirection to a specific app page. If the local - * notification handling gets more complex, we should consider decoupling it as well. - */ -'use strict'; - -import angular from 'angular'; - -angular.module('emission.splash.remotenotify', ['emission.plugin.logger', - 'emission.splash.startprefs', - 'emission.stats.clientstats']) - -.factory('RemoteNotify', function($http, $window, $ionicPopup, $rootScope, ClientStats, - CommHelper, Logger) { - - var remoteNotify = {}; - remoteNotify.options = "location=yes,clearcache=no,toolbar=yes,hideurlbar=yes"; - - /* - TODO: Potentially unify with the survey URL loading - */ - remoteNotify.launchWebpage = function(url) { - // THIS LINE FOR inAppBrowser - let iab = $window.cordova.InAppBrowser.open(url, '_blank', remoteNotify.options); - } - - remoteNotify.launchPopup = function(title, text) { - // THIS LINE FOR inAppBrowser - let alertPopup = $ionicPopup.alert({ - title: title, - template: text - }); - } - - remoteNotify.init = function() { - $rootScope.$on('cloud:push:notification', function(event, data) { - ClientStats.addEvent(ClientStats.getStatKeys().NOTIFICATION_OPEN).then( - function() { - console.log("Added "+ClientStats.getStatKeys().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)); - } - } - 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.js b/www/js/splash/startprefs.js deleted file mode 100644 index e535d179a..000000000 --- a/www/js/splash/startprefs.js +++ /dev/null @@ -1,171 +0,0 @@ -import angular from 'angular'; -import { getConfig } from '../config/dynamicConfig'; - -angular.module('emission.splash.startprefs', ['emission.plugin.logger', - 'emission.splash.referral', - 'emission.plugin.kvstore']) - -.factory('StartPrefs', function($window, $state, $interval, $rootScope, $ionicPlatform, - $ionicPopup, KVStore, $http, Logger, ReferralHandler) { - 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 - KVStore.set(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(); - KVStore.set(INTRO_DONE_KEY, currTime); - $rootScope.$emit(startprefs.INTRO_DONE_EVENT, currTime); - } - - // returns boolean - startprefs.readIntroDone = function() { - return KVStore.get(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 KVStore.get(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..5e1edd188 --- /dev/null +++ b/www/js/splash/startprefs.ts @@ -0,0 +1,116 @@ +import { storageGet, storageSet } from '../plugin/storage'; +import { logInfo, logDebug, displayErrorMsg } from '../plugin/logger'; +import { EVENTS, publish } from '../customEventHandler'; + +// 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'; + +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 + //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)); + // mark in native storage + return readConsentState() + .then(writeConsentToNative) + .then((response) => { + // mark in local storage + storageSet(DATA_COLLECTION_CONSENTED_PROTOCOL, _req_consent); + // mark in local variable as well + _curr_consented = { ..._req_consent }; + // publish event + publish(EVENTS.CONSENTED_EVENT, _req_consent); + }) + .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'); + return false; + } else { + logDebug('Consented in local storage, no need to show consent'); + return true; + } +} + +/** + * @function reads the consent state from the file and populates it + * @returns nothing, just reads into local variables + */ +export function readConsentState() { + return fetch('json/startupConfig.json') + .then((response) => response.json()) + .then((startupConfigResult) => { + _req_consent = startupConfigResult.emSensorDataCollectionProtocol; + logDebug('required consent version = ' + JSON.stringify(_req_consent)); + return storageGet(DATA_COLLECTION_CONSENTED_PROTOCOL); + }) + .then((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(); + }); +} + +/** + * @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).then( + (resultDoc) => + window['cordova'].plugins.BEMUserCache.isEmptyDoc(resultDoc) ? null : resultDoc, + ); +} + +/** + * @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((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'); + } + } + }); +} diff --git a/www/js/splash/storeDeviceSettings.ts b/www/js/splash/storeDeviceSettings.ts new file mode 100644 index 000000000..7492b35b8 --- /dev/null +++ b/www/js/splash/storeDeviceSettings.ts @@ -0,0 +1,83 @@ +import { updateUser } from '../services/commHelper'; +import { isConsented, readConsentState } from './startprefs'; +import i18next from 'i18next'; +import { displayError, logDebug } from '../plugin/logger'; +import { readIntroDone } from '../onboarding/onboardingHelper'; +import { subscribe, EVENTS, unsubscribe } from '../customEventHandler'; + +/** + * @description Gathers information about the user's device and stores it + * @returns promise to updateUser in comm settings with device info + */ +function storeDeviceSettings() { + return window['cordova'].getAppVersion + .getVersionNumber() + .then((appver) => { + const updateJSON = { + phone_lang: i18next.resolvedLanguage, + curr_platform: window['cordova'].platformId, + manufacturer: window['device'].manufacturer, + client_os_version: window['device'].version, + client_app_version: appver, + }; + logDebug('About to update profile with settings = ' + JSON.stringify(updateJSON)); + return updateUser(updateJSON); + }) + .then((updateJSON) => { + // alert("Finished saving token = "+JSON.stringify(t.token)); + }) + .catch((error) => { + displayError(error, 'Error in updating profile to store device settings'); + }); +} + +/** + * @function stores device settings on reconsent + * @param event that called this function + */ +function onConsentEvent(event) { + logDebug(`got consented event ${JSON.stringify(event['name'])} + with data ${JSON.stringify(event.detail)}`); + readIntroDone().then(async (isIntroDone) => { + if (isIntroDone) { + logDebug(`intro is done -> reconsent situation, + we already have a token -> store device settings`); + await storeDeviceSettings(); + } + }); +} + +/** + * @function stores device settings after intro received + * @param event that called this function + */ +async function onIntroEvent(event) { + logDebug(`intro is done -> original consent situation, + we should have a token by now -> store device settings`); + await storeDeviceSettings(); +} + +/** + * @function initializes store device: subscribes to events + * stores settings if already consented + */ +export function initStoreDeviceSettings() { + readConsentState() + .then(isConsented) + .then(async (consentState) => { + logDebug(`found consent: ${consentState}`); + if (consentState == true) { + await storeDeviceSettings(); + } else { + logDebug('no consent yet, waiting to store device settings in profile'); + } + subscribe(EVENTS.CONSENTED_EVENT, onConsentEvent); + subscribe(EVENTS.INTRO_DONE_EVENT, onIntroEvent); + }); + logDebug('storedevicesettings startup done'); +} + +export function teardownDeviceSettings() { + unsubscribe(EVENTS.CONSENTED_EVENT, onConsentEvent); + unsubscribe(EVENTS.INTRO_DONE_EVENT, onIntroEvent); +} diff --git a/www/js/splash/storedevicesettings.js b/www/js/splash/storedevicesettings.js deleted file mode 100644 index aaaf82c6b..000000000 --- a/www/js/splash/storedevicesettings.js +++ /dev/null @@ -1,61 +0,0 @@ -import angular from 'angular'; - -angular.module('emission.splash.storedevicesettings', ['emission.plugin.logger', - 'emission.services', - 'emission.splash.startprefs']) -.factory('StoreDeviceSettings', function($window, $state, $rootScope, $ionicPlatform, - $ionicPopup, Logger, CommHelper, StartPrefs) { - - 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 CommHelper.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; - StartPrefs.readConsentState() - .then(StartPrefs.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"); - }); - - $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()) { - 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(); - }); - - return storedevicesettings; -}); diff --git a/www/js/stats/clientstats.js b/www/js/stats/clientstats.js deleted file mode 100644 index 7fe4d9cb3..000000000 --- a/www/js/stats/clientstats.js +++ /dev/null @@ -1,92 +0,0 @@ -'use strict'; - -import angular from 'angular'; - -angular.module('emission.stats.clientstats', []) - -.factory('ClientStats', function($window) { - var clientStat = {}; - - clientStat.CLIENT_TIME = "stats/client_time"; - clientStat.CLIENT_ERROR = "stats/client_error"; - clientStat.CLIENT_NAV_EVENT = "stats/client_nav_event"; - - clientStat.getStatKeys = function() { - return { - 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" - }; - } - - clientStat.getDB = function() { - if (angular.isDefined($window) && angular.isDefined($window.cordova) && - angular.isDefined($window.cordova.plugins)) { - return $window.cordova.plugins.BEMUserCache; - } else { - return; // undefined - } - } - - clientStat.getAppVersion = function() { - if (angular.isDefined(clientStat.appVersion)) { - return clientStat.appVersion; - } else { - if (angular.isDefined($window) && angular.isDefined($window.cordova) && - angular.isDefined($window.cordova.getAppVersion)) { - $window.cordova.getAppVersion.getVersionNumber().then(function(version) { - clientStat.appVersion = version; - }); - } - return; - } - } - - clientStat.getStatsEvent = function(name, reading) { - var ts_sec = Date.now() / 1000; - var appVersion = clientStat.getAppVersion(); - return { - 'name': name, - 'ts': ts_sec, - 'reading': reading, - 'client_app_version': appVersion, - 'client_os_version': $window.device.version - }; - } - clientStat.addReading = function(name, reading) { - var db = clientStat.getDB(); - if (angular.isDefined(db)) { - return db.putMessage(clientStat.CLIENT_TIME, - clientStat.getStatsEvent(name, reading)); - } - } - - clientStat.addEvent = function(name) { - var db = clientStat.getDB(); - if (angular.isDefined(db)) { - return db.putMessage(clientStat.CLIENT_NAV_EVENT, - clientStat.getStatsEvent(name, null)); - } - } - - clientStat.addError = function(name, errorStr) { - var db = clientStat.getDB(); - if (angular.isDefined(db)) { - return db.putMessage(clientStat.CLIENT_ERROR, - clientStat.getStatsEvent(name, errorStr)); - } - } - - return clientStat; -}) diff --git a/www/js/survey/enketo/AddNoteButton.tsx b/www/js/survey/enketo/AddNoteButton.tsx index 1b85c728e..8f2b11726 100644 --- a/www/js/survey/enketo/AddNoteButton.tsx +++ b/www/js/survey/enketo/AddNoteButton.tsx @@ -7,28 +7,29 @@ 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 { DateTime } from 'luxon'; +import LabelTabContext from '../../diary/LabelTabContext'; +import EnketoModal from './EnketoModal'; +import { displayErrorMsg, logDebug } from '../../plugin/logger'; +import { isTrip } from '../../types/diaryTypes'; 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 { notesFor, addUserInputToEntry } = useContext(LabelTabContext); useEffect(() => { let newLabel: string; - const localeCode = i18n.resolvedLanguage; - if (notesConfig?.['filled-in-label'] && timelineEntry.additionsList?.length > 0) { + const localeCode = i18n.resolvedLanguage || 'en'; + if (notesConfig?.['filled-in-label'] && notesFor(timelineEntry)?.length) { newLabel = notesConfig?.['filled-in-label']?.[localeCode]; setDisplayLabel(newLabel); } else { @@ -37,76 +38,83 @@ const AddNoteButton = ({ timelineEntry, notesConfig, storeKey }: Props) => { } }, [notesConfig]); + type PrefillTimes = { + Start_date?: string; + Start_time?: string; + End_date?: string; + End_time?: string; + }; // 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; + let begin = isTrip(timelineEntry) ? timelineEntry.start_ts : timelineEntry.enter_ts; + let stop = isTrip(timelineEntry) ? 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; + notesFor(timelineEntry)?.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 momentBegin = begin ? moment(begin * 1000).tz(timezone) : null; - const momentStop = stop ? moment(stop * 1000).tz(timezone) : null; + + const timezone = + timelineEntry.start_local_dt?.timezone || + timelineEntry.enter_local_dt?.timezone || + timelineEntry.end_local_dt?.timezone || + timelineEntry.exit_local_dt?.timezone; + const beginDt = begin ? DateTime.fromSeconds(begin).setZone(timezone) : null; + const stopDt = stop ? DateTime.fromSeconds(stop).setZone(timezone) : null; // the current, local time offset (e.g. -07:00) - const currOffset = moment().toISOString(true).slice(-6); - let Start_date: string, Start_time: string, End_date: string, End_time: string; + const currOffset = DateTime.now().toISO()?.slice(-6); + const prefillTimes: PrefillTimes = {}; // enketo requires dates as YYYY-MM-DD, and times as HH:mm:ss.SSS+/-HH:mm // some may be left blank, if the timelineEntry doesn't have them - if (momentBegin) { - Start_date = momentBegin.format('YYYY-MM-DD'); - Start_time = momentBegin.format('HH:mm:ss.SSS') + currOffset; - } else { - Start_date = momentStop.format('YYYY-MM-DD'); + if (beginDt) { + prefillTimes.Start_date = beginDt.toFormat('yyyy-MM-dd'); + prefillTimes.Start_time = beginDt.toFormat('HH:mm:ss.SSS') + currOffset; + } else if (stopDt) { + prefillTimes.Start_date = stopDt.toFormat('yyyy-MM-dd'); } - if (momentStop) { - End_date = momentStop.format('YYYY-MM-DD'); - End_time = momentStop.format('HH:mm:ss.SSS') + currOffset; + if (stopDt) { + prefillTimes.End_date = stopDt.toFormat('yyyy-MM-dd'); + prefillTimes.End_time = stopDt.toFormat('HH:mm:ss.SSS') + currOffset; } - return { Start_date, Start_time, End_date, End_time }; + return prefillTimes; } function launchAddNoteSurvey() { const surveyName = notesConfig.surveyName; - console.log('About to launch survey ', surveyName); + logDebug(`AddNoteButton: 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)); - repopulateTimelineEntry(timelineEntry._id.$oid); + logDebug(`AddNoteButton: response was saved, about to addUserInputToEntry; + result = ${JSON.stringify(result)}`); + addUserInputToEntry(timelineEntry._id.$oid, result, 'note'); } else { displayErrorMsg('AddNoteButton: response was not saved, result=', result); } } - const [prefillTimes, setPrefillTimes] = useState(null); + const [prefillTimes, setPrefillTimes] = useState(undefined); 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..91cea8536 100644 --- a/www/js/survey/enketo/AddedNotesList.tsx +++ b/www/js/survey/enketo/AddedNotesList.tsx @@ -2,154 +2,191 @@ 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 { DateTime } from 'luxon'; +import { Modal } from 'react-native'; +import { Text, Button, DataTable, Dialog, Icon } from 'react-native-paper'; +import LabelTabContext from '../../diary/LabelTabContext'; +import { getFormattedDateAbbr, isMultiDay } from '../../diary/diaryHelper'; +import EnketoModal from './EnketoModal'; +import { useTranslation } from 'react-i18next'; +import { EnketoUserInputEntry } from './enketoHelper'; +import { displayErrorMsg, logDebug } from '../../plugin/logger'; type Props = { - timelineEntry: any, - additionEntries: any[], -} + timelineEntry: any; + additionEntries: EnketoUserInputEntry[]; +}; const AddedNotesList = ({ timelineEntry, additionEntries }: Props) => { - const { t } = useTranslation(); - const { repopulateTimelineEntry } = useContext(LabelTabContext); + const { addUserInputToEntry } = useContext(LabelTabContext); const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] = useState(false); const [surveyModalVisible, setSurveyModalVisible] = useState(false); - 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 beginTs = entry.data.start_ts || entry.data.enter_ts; - const stopTs = entry.data.end_ts || entry.data.exit_ts; + const [editingEntry, setEditingEntry] = useState(undefined); + + const _cachedDts = {}; + function getDisplayDt(entry?: EnketoUserInputEntry) { + if (!entry) return ''; + + // memoization: if we've already calculated the displayDt for this entry, return it from cache + const cachedDt = _cachedDts[entry.metadata.write_ts]; // write_ts used as key since it's unique + if (cachedDt) return cachedDt; + + // otherwise, calculate it and cache it before returning it + 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; + const stopTs = entry.data.end_ts; + const beginIso = DateTime.fromSeconds(beginTs).setZone(timezone).toISO() || undefined; + const stopIso = DateTime.fromSeconds(stopTs).setZone(timezone).toISO() || undefined; let d; - if (isMultiDay(beginTs, stopTs)) { - 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 = { - date: d, - time: begin + " - " + stop + if (isMultiDay(beginIso, stopIso)) { + d = getFormattedDateAbbr(beginIso, stopIso); } + const begin = DateTime.fromSeconds(beginTs) + .setZone(timezone) + .toLocaleString(DateTime.TIME_SIMPLE); + const stop = DateTime.fromSeconds(stopTs) + .setZone(timezone) + .toLocaleString(DateTime.TIME_SIMPLE); + + const dt = { date: d, time: begin + ' - ' + stop }; + _cachedDts[entry.metadata.write_ts] = dt; + return dt; } - function deleteEntry(entry) { - console.log("Deleting entry", entry); + function deleteEntry(entry?: EnketoUserInputEntry) { + const dataKey = entry?.data?.key || entry?.metadata?.key; + const data = entry?.data; + + if (!dataKey || !data) { + return displayErrorMsg(`Error in deleteEntry, entry was: ${JSON.stringify(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(() => { + logDebug(`Deleting entry ${JSON.stringify(entry)} + with dataKey ${dataKey}; + index = ${index}`); + + return window['cordova'].plugins.BEMUserCache.putMessage(dataKey, data).then(() => { + // if entry was found in additionEntries, remove it + if (index > -1) { additionEntries.splice(index, 1); - setConfirmDeleteModalVisible(false); - setEditingEntry(null); - }); + } + setConfirmDeleteModalVisible(false); + setEditingEntry(undefined); + }); } - function confirmDeleteEntry(entry) { + function confirmDeleteEntry(entry: EnketoUserInputEntry) { setEditingEntry(entry); setConfirmDeleteModalVisible(true); } function dismissConfirmDelete() { - setEditingEntry(null); + setEditingEntry(undefined); setConfirmDeleteModalVisible(false); } function editEntry(entry) { setEditingEntry(entry); + logDebug('editingEntry = ' + JSON.stringify(entry)); setSurveyModalVisible(true); } - async function onEditedResponse(response) { + async function onEditedResponse(response: EnketoUserInputEntry) { if (!response) return; await deleteEntry(editingEntry); - setEditingEntry(null); - repopulateTimelineEntry(timelineEntry._id.$oid); + setEditingEntry(undefined); + addUserInputToEntry(timelineEntry._id.$oid, response, 'note'); } function onModalDismiss() { - setEditingEntry(null); + setEditingEntry(undefined); setSurveyModalVisible(false); } 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' }]}> + + {entry.data.label} + + + editEntry(entry)} + style={[styles.cell, { flex: 4 }]} + textStyle={{ fontSize: 12, lineHeight: 12 }}> + {getDisplayDt(entry)?.date} + {getDisplayDt(entry)?.time} + + confirmDeleteEntry(entry)} + style={[styles.cell, { flex: 1, justifyContent: 'center' }]}> + + + + ); + })} + + {editingEntry && ( + + )} + + + {t('diary.delete-entry-confirm')} + + {editingEntry?.data?.label} + {getDisplayDt(editingEntry)?.date} + {getDisplayDt(editingEntry)?.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..f8b503407 100644 --- a/www/js/survey/enketo/EnketoModal.tsx +++ b/www/js/survey/enketo/EnketoModal.tsx @@ -1,66 +1,52 @@ import React, { useRef, useEffect } from 'react'; import { Form } from 'enketo-core'; import { StyleSheet, Modal, ScrollView, SafeAreaView, Pressable } from 'react-native'; -import { ModalProps } from 'react-native-paper'; +import { Button, Icon, ModalProps } from 'react-native-paper'; import useAppConfig from '../../useAppConfig'; import { useTranslation } from 'react-i18next'; -import { SurveyOptions, getInstanceStr, saveResponse } from './enketoHelper'; -import { fetchUrlCached } from '../../commHelper'; -import { displayError, displayErrorMsg } from '../../plugin/logger'; -// import { transform } from 'enketo-transformer/web'; +import { SurveyOptions, fetchSurvey, getInstanceStr, saveResponse } from './enketoHelper'; +import { displayError, displayErrorMsg, logDebug } from '../../plugin/logger'; 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); + const headerEl = useRef(null); + const surveyJson = useRef(null); const enketoForm = useRef
(null); const appConfig = useAppConfig(); - async function fetchSurveyJson(url) { - const responseText = await fetchUrlCached(url); - try { - return JSON.parse(responseText); - } catch ({name, message}) { - // not JSON, so it must be XML - 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(); - // return await transform({xform: xmlText}); - } - } - async function validateAndSave() { const valid = await enketoForm.current.validate(); if (!valid) return false; - const result = await saveResponse(surveyName, enketoForm.current, appConfig, opts); - if (!result) { // validation failed - displayErrorMsg(t('survey.enketo-form-errors')); - } else if (result instanceof Error) { // error thrown in saveResponse - displayError(result); - } else { // success - rest.onDismiss(); - onResponseSaved(result); - return; + try { + const result = await saveResponse(surveyName, enketoForm.current, appConfig, opts); + if (result) { + // success + rest.onDismiss?.(); + onResponseSaved(result); + } else { + // validation failed + displayErrorMsg(t('survey.enketo-form-errors')); + } + } catch (err) { + displayError(err); } } - // 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); + logDebug('EnketoModal: loading survey ' + surveyName); const formPath = appConfig.survey_info?.surveys?.[surveyName]?.formPath; - if (!formPath) return console.error('No form path found for survey', surveyName); + if (!formPath) return displayErrorMsg('No form path found for survey ' + surveyName); - fetchSurveyJson(formPath).then(({ form, model }) => { + fetchSurvey(formPath).then(({ form, model }) => { surveyJson.current = { form, model }; - headerEl?.current.insertAdjacentHTML('afterend', form); // inject form into DOM + headerEl?.current?.insertAdjacentHTML('afterend', form); // inject form into DOM const formEl = document.querySelector('form.or'); const data = { modelStr: model, // the XML model for this form @@ -75,28 +61,31 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest } : Props) => } useEffect(() => { - if (!rest.visible) return; - if (!appConfig) return console.error('App config not loaded yet'); + if (!rest.visible || !appConfig) return; initSurvey(); }, [appConfig, rest.visible]); /* adapted from the template given by enketo-core: https://github.com/enketo/enketo-core/blob/master/src/index.html */ const enketoContent = ( -
+
{/* This form header (markup/css) can be changed in the application. Just make sure to keep a .form-language-selector element into which the form language selector ( @@ -108,19 +97,53 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest } : Props) => {/* The retrieved form will be injected here */} -
+
{/* 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.powered-by')}{' '} + + enketo logo + +
{/*
    */}
    @@ -129,19 +152,17 @@ const EnketoModal = ({ surveyName, onResponseSaved, opts, ...rest } : Props) => ); return ( - - - - -
    - {enketoContent} -
    + + + + +
    {enketoContent}
    ); -} +}; const s = StyleSheet.create({ dismissBtn: { @@ -152,7 +173,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..e3e629bda 100644 --- a/www/js/survey/enketo/UserInputButton.tsx +++ b/www/js/survey/enketo/UserInputButton.tsx @@ -8,68 +8,88 @@ 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 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/LabelTabContext'; +import useAppConfig from '../../useAppConfig'; +import { getSurveyForTimelineEntry } from './conditionalSurveys'; +import useDerivedProperties from '../../diary/useDerivedProperties'; type Props = { - timelineEntry: any, -} + timelineEntry: any; +}; const UserInputButton = ({ timelineEntry }: Props) => { const { colors } = useTheme(); + const appConfig = useAppConfig(); const { t, i18n } = useTranslation(); - const [prevSurveyResponse, setPrevSurveyResponse] = useState(null); + const [prevSurveyResponse, setPrevSurveyResponse] = useState(undefined); const [modalVisible, setModalVisible] = useState(false); - const { repopulateTimelineEntry } = useContext(LabelTabContext); + const { userInputFor, addUserInputToEntry } = useContext(LabelTabContext); + const derivedTripProps = useDerivedProperties(timelineEntry); - const EnketoTripButtonService = getAngularService("EnketoTripButtonService"); - const etbsSingleKey = EnketoTripButtonService.SINGLE_KEY; + // which survey will this button launch? + const [surveyName, notFilledInLabel] = useMemo(() => { + if (!appConfig) return []; // no config loaded yet; show blank for now + const tripLabelConfig = appConfig?.survey_info?.buttons?.['trip-label']; + if (!tripLabelConfig) { + // config doesn't specify; use default + return ['TripConfirmSurvey', t('diary.choose-survey')]; + } + // config lists one or more surveys; find which one to use + const s = getSurveyForTimelineEntry(tripLabelConfig, timelineEntry, derivedTripProps); + const lang = i18n.resolvedLanguage || 'en'; + return [s?.surveyName, s?.['not-filled-in-label'][lang]]; + }, [appConfig, timelineEntry, i18n.resolvedLanguage]); // 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( + () => userInputFor(timelineEntry)?.['SURVEY']?.data.label || undefined, + [userInputFor(timelineEntry)?.['SURVEY']?.data.label], + ); function launchUserInputSurvey() { logDebug('UserInputButton: About to launch survey'); - const prevResponse = timelineEntry.userInput?.[etbsSingleKey]; - setPrevSurveyResponse(prevResponse?.data?.xmlResponse); + const prevResponse = userInputFor(timelineEntry)?.['SURVEY']; + if (prevResponse?.data?.xmlResponse) { + setPrevSurveyResponse(prevResponse.data.xmlResponse); + } setModalVisible(true); } function onResponseSaved(result) { if (result) { - logDebug('UserInputButton: response was saved, about to repopulateTimelineEntry; result=' + JSON.stringify(result)); - repopulateTimelineEntry(timelineEntry._id.$oid); + logDebug(`UserInputButton: response was saved, about to addUserInputToEntry; + result = ${JSON.stringify(result)}`); + addUserInputToEntry(timelineEntry._id.$oid, { SURVEY: result }, 'label'); } else { displayErrorMsg('UserInputButton: response was not saved, result=', result); } } - return (<> - launchUserInputSurvey()}> - {/* if no response yet, show the default label */} - {responseLabel || t('diary.choose-survey')} - + if (!surveyName) return <>; // no survey to launch + return ( + <> + launchUserInputSurvey()}> + {responseLabel || notFilledInLabel} + - 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 - }} /> - ); + setModalVisible(false)} + onResponseSaved={onResponseSaved} + surveyName={surveyName} + opts={{ timelineEntry, prefilledSurveyResponse: prevSurveyResponse }} + /> + + ); }; export default UserInputButton; diff --git a/www/js/survey/enketo/answer.js b/www/js/survey/enketo/answer.js deleted file mode 100644 index e6077c479..000000000 --- a/www/js/survey/enketo/answer.js +++ /dev/null @@ -1,193 +0,0 @@ -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!`) - } - } - - // 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; - } - - /** - * _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 - ) - ); - } - - /** - * 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 - } - - /* 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 - return { - start_ts: additionStartTs, - end_ts: additionEndTs - }; - } - - return { - filterByNameAndVersion, - resolveLabel, - resolveTimestamps, - }; -}); diff --git a/www/js/survey/enketo/conditionalSurveys.ts b/www/js/survey/enketo/conditionalSurveys.ts new file mode 100644 index 000000000..607b49431 --- /dev/null +++ b/www/js/survey/enketo/conditionalSurveys.ts @@ -0,0 +1,56 @@ +import { displayError } from '../../plugin/logger'; +import { SurveyButtonConfig } from '../../types/appConfigTypes'; +import { DerivedProperties, TimelineEntry } from '../../types/diaryTypes'; +import { Position } from 'geojson'; + +const conditionalSurveyFunctions = { + /** + @description Returns true if the given point is within the given bounds. + Coordinates are in [longitude, latitude] order, since that is the GeoJSON spec. + @param pt point to check as [lon, lat] + @param bounds NW and SE corners as [[lon, lat], [lon, lat]] + @returns true if pt is within bounds + */ + pointIsWithinBounds: (pt: Position, bounds: Position[]) => { + // pt's lon must be east of, or greater than, NW's lon; and west of, or less than, SE's lon + const lonInRange = pt[0] > bounds[0][0] && pt[0] < bounds[1][0]; + // pt's lat must be south of, or less than, NW's lat; and north of, or greater than, SE's lat + const latInRange = pt[1] < bounds[0][1] && pt[1] > bounds[1][1]; + return latInRange && lonInRange; + }, +}; + +/** + * @description Executes a JS expression `script` in a restricted `scope` + * @example scopedEval('console.log(foo)', { foo: 'bar' }) + */ +const scopedEval = (script: string, scope: { [k: string]: any }) => + Function(...Object.keys(scope), `return ${script}`)(...Object.values(scope)); + +// the first survey in the list that passes its condition will be returned +export function getSurveyForTimelineEntry( + tripLabelConfig: SurveyButtonConfig | SurveyButtonConfig[], + tlEntry: TimelineEntry, + derivedProperties: DerivedProperties, +) { + // if only one survey is given, just return it + if (!(tripLabelConfig instanceof Array)) return tripLabelConfig; + if (tripLabelConfig.length == 1) return tripLabelConfig[0]; + // else we have an array of possible surveys, we need to find which one to use for this entry + for (let surveyConfig of tripLabelConfig) { + if (!surveyConfig.showsIf) return surveyConfig; // survey shows unconditionally + const scope = { + ...tlEntry, + ...derivedProperties, + ...conditionalSurveyFunctions, + }; + try { + const evalResult = scopedEval(surveyConfig.showsIf, scope); + if (evalResult) return surveyConfig; + } catch (e) { + displayError(e, `Error evaluating survey condition "${surveyConfig.showsIf}"`); + } + } + // TODO if none of the surveys passed conditions?? should we return null, throw error, or return a default? + return null; +} diff --git a/www/js/survey/enketo/enketo-add-note-button.js b/www/js/survey/enketo/enketo-add-note-button.js deleted file mode 100644 index a2f0d1557..000000000 --- a/www/js/survey/enketo/enketo-add-note-button.js +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Directive to display a survey to add notes to a timeline entry (trip or place) - */ - -import angular from 'angular'; - -angular.module('emission.survey.enketo.add-note-button', - ['emission.stats.clientstats', - '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") - } - } - - /** - * 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.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+")"); - - 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 => { - timelineEntryField.push(ta); - }); - } - } - - return enbs; -}); diff --git a/www/js/survey/enketo/enketo-trip-button.js b/www/js/survey/enketo/enketo-trip-button.js deleted file mode 100644 index 5b385a1ac..000000000 --- a/www/js/survey/enketo/enketo-trip-button.js +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Directive to display a survey for each trip - * Assumptions: - * - The directive is embedded within an ion-view - * - The controller for the ion-view has a function called - * 'recomputeListEntries` which modifies the *list* of trips and places - * as necessary. An example with the label view is removing the labeled trips from - * the "toLabel" filter. Function can be a no-op (for example, in the diary view) - * - The view is associated with a state which we can record in the client stats. - * - The directive implements a `verifyTrip` function that can be invoked by - * other components. - */ - -import angular from 'angular'; - -angular.module('emission.survey.enketo.trip.button', - ['emission.stats.clientstats', - '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); - - 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"); - } - } - - /** - * 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; - } - - /** - * 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; - } - - /** - * 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; - } - - return etbs; -}); diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts index 6e9147cf8..2df2d3b2d 100644 --- a/www/js/survey/enketo/enketoHelper.ts +++ b/www/js/survey/enketo/enketoHelper.ts @@ -1,19 +1,141 @@ -import { getAngularService } from "../../angular-react-helper"; import { Form } from 'enketo-core'; +import { transform } from 'enketo-transformer/web'; import { XMLParser } from 'fast-xml-parser'; import i18next from 'i18next'; -import { logDebug } from "../../plugin/logger"; +import MessageFormat from '@messageformat/core'; +import { logDebug, logInfo } from '../../plugin/logger'; +import { getConfig } from '../../config/dynamicConfig'; +import { DateTime } from 'luxon'; +import { fetchUrlCached } from '../../services/commHelper'; +import { getUnifiedDataForInterval } from '../../services/unifiedDataLoader'; +import { AppConfig, EnketoSurveyConfig } from '../../types/appConfigTypes'; +import { + CompositeTrip, + ConfirmedPlace, + TimelineEntry, + TimestampRange, + UserInputData, + UserInputEntry, + isTrip, +} from '../../types/diaryTypes'; -export type PrefillFields = {[key: string]: string}; +export type PrefillFields = { [key: string]: string }; export type SurveyOptions = { undismissable?: boolean; - timelineEntry?: any; + timelineEntry?: TimelineEntry; prefilledSurveyResponse?: string; prefillFields?: PrefillFields; dataKey?: string; }; +type EnketoResponseData = { + start_ts?: number; //start timestamp (in seconds) + end_ts?: number; //end timestamp (in seconds) + label: string; //display label (this value is use for displaying on the button) + ts: number; //the timestamp at which the survey was filled out (in seconds) + fmt_time: string; //the formatted timestamp at which the survey was filled out + name: string; //survey name + version: number; //survey version + key?: string; //data key + xmlResponse: string; //survey response as XML string + jsonDocResponse: { [k: string]: any }; //survey response as JSON object +}; + +type EnketoResponse = { + data: EnketoResponseData; //survey response data + metadata: any; +}; + +export type EnketoUserInputData = UserInputData & { + key?: string; + version: number; + xmlResponse: string; + jsonDocResponse: { [k: string]: any }; +}; +export type EnketoUserInputEntry = UserInputEntry; + +const LABEL_FUNCTIONS = { + UseLabelTemplate: async (xmlDoc: XMLDocument, name: string) => { + let appConfig = await getConfig(); + const configSurveys = appConfig.survey_info.surveys; + + const config = configSurveys[name]; // config for this survey + const lang = i18next.resolvedLanguage || 'en'; + 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: string | null = _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 + }, +}; + +/** + * _getAnswerByTagName look up how a question was answered, given the survey response + * and the tag name of the question + * @param {XMLDocument} xmlDoc survey response as XML object + * @param {string} tagName tag name of the question + * @returns {string} answer string. If not found, return "\" + */ +function _getAnswerByTagName(xmlDoc: XMLDocument, tagName: string) { + const vals = xmlDoc.getElementsByTagName(tagName); + const val = vals.length ? vals[0].innerHTML : null; + if (!val) return ''; + return val; +} + +/** @type {EnketoSurveyConfig} _config */ +let _config: EnketoSurveyConfig; + +/** + * filterByNameAndVersion filter the survey responses by survey name and their version. + * The version for filtering is specified in enketo survey `compatibleWith` config. + * The survey version of the response must be greater than or equal to `compatibleWith` to be included. + * @param {string} name survey name (defined in enketo survey config) + * @param {EnketoResponse[]} responses An array of previously recorded responses to Enketo surveys + * (presumably having been retrieved from unifiedDataLoader) + * @param {AppConfig} appConfig the dynamic config file for the app + * @return {Promise} filtered survey responses + */ +export function filterByNameAndVersion(name: string, responses: EnketoResponse[], appConfig) { + return responses.filter( + (r) => + r.data.name === name && r.data.version >= appConfig.survey_info.surveys[name].compatibleWith, + ); +} +/** + * resolve a label for the survey response + * @param {string} name survey name + * @param {XMLDocument} xmlDoc survey response as XML object + * @returns {Promise} label string Promise + */ +export async function resolveLabel(name: string, xmlDoc: XMLDocument) { + // 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 await LABEL_FUNCTIONS[name](xmlDoc); + return await LABEL_FUNCTIONS.UseLabelTemplate(xmlDoc, name); +} + /** * @param xmlModel the blank XML model to be prefilled * @param prefillFields an object with keys that are the XML tag names and values that are the values to be prefilled @@ -21,7 +143,7 @@ export type SurveyOptions = { */ function getXmlWithPrefills(xmlModel: string, prefillFields: PrefillFields) { if (!prefillFields) return null; - const xmlParser = new window.DOMParser(); + const xmlParser = new DOMParser(); const xmlDoc = xmlParser.parseFromString(xmlModel, 'text/xml'); for (const [tagName, value] of Object.entries(prefillFields)) { @@ -37,79 +159,169 @@ 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; } +/** + * resolve timestamps label from the survey response + * @param {XMLDocument} xmlDoc survey response as XML object + * @param {object} timelineEntry trip or place object + * @param {function} onFail callback function to be called if timestamp validation fails + * @returns {object} object with `start_ts` and `end_ts` + * - null if no timestamps are resolved + */ +export function resolveTimestamps( + xmlDoc: XMLDocument, + timelineEntry: TimelineEntry, + onFail: (e: Error) => void, +) { + // 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 as CompositeTrip).start_local_dt?.timezone || + (timelineEntry as ConfirmedPlace).enter_local_dt?.timezone || + (timelineEntry as CompositeTrip).end_local_dt?.timezone || + (timelineEntry as ConfirmedPlace).exit_local_dt?.timezone; + // split by + or - to get time without offset + startTime = startTime.split(/\-|\+/)[0]; + endTime = endTime.split(/\-|\+/)[0]; + + let additionStartTs = DateTime.fromISO(startDate + 'T' + startTime, { + zone: timezone, + }).toSeconds(); + let additionEndTs = DateTime.fromISO(endDate + 'T' + endTime, { zone: timezone }).toSeconds(); + + if (additionStartTs > additionEndTs) { + onFail(new Error(i18next.t('survey.enketo-timestamps-invalid'))); //"Timestamps are invalid. Please ensure that the start time is before the end time."); + return; + } + + /* 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 as CompositeTrip).start_ts || (timelineEntry as ConfirmedPlace).enter_ts; + const entryEndTs = + (timelineEntry as CompositeTrip).end_ts || (timelineEntry as ConfirmedPlace).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, + }; +} + /** * @param surveyName the name of the survey (e.g. "TimeUseSurvey") * @param enketoForm the Form object from enketo-core that contains this survey * @param appConfig the dynamic config file for the app * @param opts object with SurveyOptions like 'timelineEntry' or 'dataKey' - * @returns Promise of the saved result, or an Error if there was a problem + * @returns Promise of the saved result. May reject if there was a problem */ -export function saveResponse(surveyName: string, enketoForm: Form, appConfig, opts: SurveyOptions) { - const EnketoSurveyAnswer = getAngularService('EnketoSurveyAnswer'); +export function saveResponse( + surveyName: string, + enketoForm: Form, + appConfig: AppConfig, + opts?: SurveyOptions, +) { 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 resolveLabel(surveyName, xmlDoc) + .then((rsLabel) => { + let timestamps: TimestampRange | { ts: number; fmt_time: string } | undefined; + let match_id: string | undefined; + if (opts?.timelineEntry) { + const resolvedTimestamps = resolveTimestamps(xmlDoc, opts.timelineEntry, (errOnFail) => { + return Promise.reject(errOnFail); + }); + if (resolvedTimestamps?.start_ts && resolvedTimestamps?.end_ts) { + timestamps = resolvedTimestamps; + } else { + // if timestamps were not resolved from the survey, we will try the trip or place timestamps + timestamps = { + start_ts: isTrip(opts.timelineEntry) + ? opts.timelineEntry.start_ts + : opts.timelineEntry.enter_ts, + end_ts: isTrip(opts.timelineEntry) + ? opts.timelineEntry.end_ts + : opts.timelineEntry.exit_ts, + }; + } + // UUID generated using this method https://stackoverflow.com/a/66332305 + match_id = URL.createObjectURL(new Blob([])).slice(-36); + } else { + const now = new Date(); + timestamps = { + ts: now.getTime() / 1000, // epoch seconds to be consistent with the server + fmt_time: now.toISOString(), + }; } - // 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; + const data: EnketoUserInputData | EnketoResponseData = { + ...(timestamps || {}), + name: surveyName, + version: appConfig.survey_info.surveys[surveyName].version, + label: rsLabel, + match_id, + key: dataKey, + xmlResponse, + jsonDocResponse, + }; + 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]); - return answers[0]; +function _getMostRecent(responses) { + responses.sort((a, b) => a.metadata.write_ts < b.metadata.write_ts); + logDebug(`_getMostRecent: first response is ${responses[0]}; + last response is ${responses.slice(-1)[0]}`); + return responses[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); + const getMethod = window['cordova'].plugins.BEMUserCache.getMessagesForInterval; + return getUnifiedDataForInterval(dataKey, tq, getMethod).then((responses) => + _getMostRecent(responses), + ); +} + +export async function fetchSurvey(url: string) { + const responseText = await fetchUrlCached(url); + if (!responseText) return; + try { + return JSON.parse(responseText); + } catch (e) { + logDebug(`${e.name}: Survey was not in JSON format. Attempting to transform XML -> JSON...`); + return await transform({ xform: responseText }); + } } diff --git a/www/js/survey/enketo/infinite_scroll_filters.js b/www/js/survey/enketo/infinite_scroll_filters.js deleted file mode 100644 index 8e45db8e4..000000000 --- a/www/js/survey/enketo/infinite_scroll_filters.js +++ /dev/null @@ -1,40 +0,0 @@ -'use strict'; - -/* - * The general structure of this code is that all the timeline information for - * a particular day is retrieved from the Timeline factory and put into the scope. - * For best performance, all data should be loaded into the in-memory timeline, - * and in addition to writing to storage, the data should be written to memory. - * All UI elements should only use $scope variables. - */ - -import angular from 'angular'; - -angular.module('emission.survey.enketo.trip.infscrollfilters',[ - 'emission.survey.enketo.trip.button', - 'emission.plugin.logger' - ]) -.factory('EnketoTripInfScrollFilters', function(Logger, EnketoTripButtonService){ - var sf = {}; - var unlabeledCheck = function(t) { - return !angular.isDefined(t.userInput[EnketoTripButtonService.SINGLE_KEY]); - } - - sf.UNLABELED = { - key: "unlabeled", - text: i18next.t("diary.unlabeled"), - filter: unlabeledCheck - } - - sf.TO_LABEL = { - key: "to_label", - text: i18next.t("diary.to-label"), - filter: unlabeledCheck - } - - sf.configuredFilters = [ - sf.TO_LABEL, - sf.UNLABELED, - ]; - return sf; -}); diff --git a/www/js/survey/enketo/infinite_scroll_filters.ts b/www/js/survey/enketo/infinite_scroll_filters.ts new file mode 100644 index 000000000..d4b281713 --- /dev/null +++ b/www/js/survey/enketo/infinite_scroll_filters.ts @@ -0,0 +1,19 @@ +/* + * The general structure of this code is that all the timeline information for + * a particular day is retrieved from the Timeline factory and put into the scope. + * For best performance, all data should be loaded into the in-memory timeline, + * and in addition to writing to storage, the data should be written to memory. + * All UI elements should only use $scope variables. + */ + +import i18next from 'i18next'; + +const unlabeledCheck = (trip, userInputForTrip) => !userInputForTrip?.['SURVEY']; + +const TO_LABEL = { + key: 'to_label', + text: i18next.t('diary.to-label'), + filter: unlabeledCheck, +}; + +export const configuredFilters = [TO_LABEL]; diff --git a/www/js/survey/input-matcher.js b/www/js/survey/input-matcher.js deleted file mode 100644 index 2e3d5b908..000000000 --- a/www/js/survey/input-matcher.js +++ /dev/null @@ -1,213 +0,0 @@ -'use strict'; - -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) { - Logger.log(`Draft trip: - comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} - -> ${fmtTs(userInput.data.end_ts, userInput.metadata.time_zone)} - trip = ${fmtTs(trip.start_ts, userInput.metadata.time_zone)} - -> ${fmtTs(trip.end_ts, userInput.metadata.time_zone)} - checks are (${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} - `); - } - 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 - 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) { - // 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) { - Logger.log(`Cleaned trip: - comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} - -> ${fmtTs(userInput.data.end_ts, userInput.metadata.time_zone)} - trip = ${fmtTs(entryStart, userInput.metadata.time_zone)} - -> ${fmtTs(entryStart, userInput.metadata.time_zone)} - start checks are ${userInput.data.start_ts >= entryStart} - && ${userInput.data.start_ts < entryEnd} - 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) - 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, - or within 15 minutes. */ - 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); - } - } 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); - } - 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; - } - } - 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; - } - - im.getUserInputForTrip = function(trip, nextTrip, userInputList) { - const logsEnabled = userInputList.length < 20; - - 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) { - 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])); - return potentialCandidates[0]; - } - - 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) => { - 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"); - } - } else { - uniqueMap.set(e.data.match_id, e); - } - }); - return Array.from(uniqueMap.values()); - } - - return im; -}); diff --git a/www/js/survey/inputMatcher.ts b/www/js/survey/inputMatcher.ts new file mode 100644 index 000000000..b1460194e --- /dev/null +++ b/www/js/survey/inputMatcher.ts @@ -0,0 +1,436 @@ +import { logDebug, displayErrorMsg } from '../plugin/logger'; +import { DateTime } from 'luxon'; +import { + BluetoothBleData, + CompositeTrip, + ConfirmedPlace, + TimelineEntry, + UserInputEntry, +} from '../types/diaryTypes'; +import { + keysForLabelInputs, + unprocessedBleScans, + unprocessedLabels, + unprocessedNotes, +} from '../diary/timelineHelper'; +import { + getLabelInputDetails, + inputType2retKey, + removeManualPrefix, +} from './multilabel/confirmHelper'; +import { TimelineLabelMap, TimelineNotesMap } from '../diary/LabelTabContext'; +import { MultilabelKey } from '../types/labelTypes'; +import { EnketoUserInputEntry } from './enketo/enketoHelper'; +import { AppConfig } from '../types/appConfigTypes'; +import { BEMData } from '../types/serverData'; + +const EPOCH_MAXIMUM = 2 ** 31 - 1; + +export const fmtTs = (ts_in_secs: number, tz: string): false | string | null => + ts_in_secs && tz ? DateTime.fromSeconds(ts_in_secs, { zone: tz }).toISO() : null; + +export const printUserInput = (ui: UserInputEntry): string => + `${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}`; + +export function validUserInputForDraftTrip( + trip: CompositeTrip, + userInput: UserInputEntry, + logsEnabled: boolean, +): boolean { + if (logsEnabled) { + logDebug(`Draft trip: + comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} + -> ${fmtTs(userInput.data.end_ts, userInput.metadata.time_zone)} + trip = ${fmtTs(trip.start_ts, userInput.metadata.time_zone)} + -> ${fmtTs(trip.end_ts, userInput.metadata.time_zone)} + checks are (${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} + `); + } + + 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 + ); +} + +export function validUserInputForTimelineEntry( + tlEntry: TimelineEntry, + nextEntry: TimelineEntry | null, + userInput: UserInputEntry, + logsEnabled: boolean, +): boolean { + if (!tlEntry.origin_key) return false; + if (tlEntry.origin_key.includes('UNPROCESSED')) + return validUserInputForDraftTrip(tlEntry as CompositeTrip, userInput, logsEnabled); + + /* 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 as CompositeTrip).start_ts || (tlEntry as ConfirmedPlace).enter_ts; + let entryEnd = (tlEntry as CompositeTrip).end_ts || (tlEntry as ConfirmedPlace).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 = DateTime.fromSeconds(entryEnd).startOf('day').toUnixInteger(); + } + + 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) { + logDebug(`Cleaned trip: + comparing user = ${fmtTs(userInput.data.start_ts, userInput.metadata.time_zone)} + -> ${fmtTs(userInput.data.end_ts, userInput.metadata.time_zone)} + trip = ${fmtTs(entryStart, userInput.metadata.time_zone)} + -> ${fmtTs(entryStart, userInput.metadata.time_zone)} + start checks are ${userInput.data.start_ts >= entryStart} + && ${userInput.data.start_ts < entryEnd} + 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) + 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, + or within 15 minutes. */ + let endChecks = userInput.data.end_ts <= entryEnd || userInput.data.end_ts - entryEnd <= 15 * 60; + + if (startChecks && !endChecks) { + if (nextEntry) { + const nextEntryEnd = + (nextEntry as CompositeTrip).end_ts || (nextEntry as ConfirmedPlace).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; + logDebug(`Second level of end checks when the next trip is defined, + (${userInput.data.end_ts} <= ${nextEntryEnd}), + endChecks = ${endChecks}`); + } + } else { + // next trip is not defined, last trip + endChecks = userInput.data.end_local_dt?.day == userInput.data.start_local_dt?.day; + logDebug('Second level of end checks for the last trip of the day'); + logDebug(`compare ${userInput.data.end_local_dt?.day} with ${userInput.data.start_local_dt?.day}; + endChecks = ${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); + logDebug(`Flipped endCheck, overlapDuration / tlEntry.duration is + ${overlapDuration} / ${tlEntry.duration} = ${overlapDuration / tlEntry.duration}`); + endChecks = overlapDuration / tlEntry.duration > 0.5; + } + } + return startChecks && endChecks; +} + +// parallels get_not_deleted_candidates() in trip_queries.py +export function getNotDeletedCandidates(candidates: UserInputEntry[]): UserInputEntry[] { + logDebug('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'])); + + logDebug(`Found ${allActiveList.length} active entries; + ${allDeletedIds.length} deleted entries -> + ${notDeletedActive.length} non-deleted active entries`); + + return notDeletedActive; +} + +export function getUserInputForTimelineEntry( + entry: TimelineEntry, + nextEntry: TimelineEntry | null, + userInputList: UserInputEntry[], +): undefined | UserInputEntry { + const logsEnabled = userInputList?.length < 20; + if (userInputList === undefined) { + logDebug('In getUserInputForTimelineEntry, no user input, returning undefined'); + return undefined; + } + + if (logsEnabled) logDebug(`Input list = ${userInputList.map(printUserInput)}`); + + // undefined !== true, so this covers the label view case as well + const potentialCandidates = userInputList.filter((ui) => + validUserInputForTimelineEntry(entry, nextEntry, ui, logsEnabled), + ); + + if (potentialCandidates.length === 0) { + if (logsEnabled) + logDebug('In getUserInputForTimelineEntry, no potential candidates, returning []'); + return undefined; + } + + if (potentialCandidates.length === 1) { + logDebug(`In getUserInputForTimelineEntry, one potential candidate, + returning ${printUserInput(potentialCandidates[0])}`); + return potentialCandidates[0]; + } + + logDebug(`potentialCandidates are ${potentialCandidates.map(printUserInput)}`); + + const sortedPC = potentialCandidates.sort( + (pc1, pc2) => pc2.metadata.write_ts - pc1.metadata.write_ts, + ); + const mostRecentEntry = sortedPC[0]; + logDebug('Returning mostRecentEntry ' + printUserInput(mostRecentEntry)); + + return mostRecentEntry; +} + +// return array of matching additions for a trip or place +export function getAdditionsForTimelineEntry( + entry: TimelineEntry, + nextEntry: TimelineEntry | null, + additionsList: EnketoUserInputEntry[], +): UserInputEntry[] { + const logsEnabled = additionsList?.length < 20; + + if (additionsList === undefined) { + logDebug('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) => + validUserInputForTimelineEntry(entry, nextEntry, ui, logsEnabled), + ); + + if (logsEnabled) logDebug(`Matching Addition list ${matchingAdditions.map(printUserInput)}`); + + return matchingAdditions; +} + +export function getUniqueEntries(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) { + displayErrorMsg( + 'Found ' + allDeleted.length + ' non-ACTIVE addition entries while trying to dedup entries', + JSON.stringify(allDeleted), + ); + } + + 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 + ) { + displayErrorMsg( + `Found two ACTIVE entries with the same match ID but different timestamps ${existingVal.data.match_id}`, + `${JSON.stringify(existingVal)} vs ${JSON.stringify(e)}`, + ); + } else { + logDebug(`Found two entries with match_id ${existingVal.data.match_id}, + but they are identical`); + } + } else { + uniqueMap.set(e.data.match_id, e); + } + }); + return Array.from(uniqueMap.values()); +} + +/** + * @param allEntries the array of timeline entries to map inputs to + * @returns an array containing: (i) an object mapping timeline entry IDs to label inputs, + * and (ii) an object mapping timeline entry IDs to note inputs + */ +export function mapInputsToTimelineEntries( + allEntries: TimelineEntry[], + appConfig: AppConfig, +): [TimelineLabelMap, TimelineNotesMap] { + const timelineLabelMap: TimelineLabelMap = {}; + const timelineNotesMap: TimelineNotesMap = {}; + + allEntries.forEach((tlEntry, i) => { + const nextEntry = i + 1 < allEntries.length ? allEntries[i + 1] : null; + if (appConfig?.survey_info?.['trip-labels'] == 'ENKETO') { + // ENKETO configuration: just look for the 'SURVEY' key in the unprocessedInputs + const userInputForTrip = getUserInputForTimelineEntry( + tlEntry, + nextEntry, + unprocessedLabels['SURVEY'], + ) as EnketoUserInputEntry; + if (userInputForTrip) { + timelineLabelMap[tlEntry._id.$oid] = { SURVEY: userInputForTrip }; + } else { + let processedSurveyResponse; + for (const dataKey of keysForLabelInputs(appConfig)) { + const key = removeManualPrefix(dataKey); + if (tlEntry.user_input?.[key]) { + processedSurveyResponse = tlEntry.user_input[key]; + break; + } + } + timelineLabelMap[tlEntry._id.$oid] = { SURVEY: processedSurveyResponse }; + } + } else { + // MULTILABEL configuration: use the label inputs from the labelOptions to determine which + // keys to look for in the unprocessedInputs + const labelsForTrip: { [k: string]: UserInputEntry | undefined } = {}; + Object.keys(getLabelInputDetails(appConfig)).forEach((label: MultilabelKey) => { + // Check unprocessed labels first since they are more recent + const userInputForTrip = getUserInputForTimelineEntry( + tlEntry, + nextEntry, + unprocessedLabels[label], + ) as UserInputEntry; + if (userInputForTrip) { + labelsForTrip[label] = userInputForTrip; + } else { + const processedLabelValue = tlEntry.user_input?.[inputType2retKey(label)]; + if (processedLabelValue) { + // TODO: when we unify the user input types on the server, we can remove this 'any' cast + labelsForTrip[label] = { data: { label: processedLabelValue } } as any; + } + } + }); + if (Object.keys(labelsForTrip).length) { + timelineLabelMap[tlEntry._id.$oid] = labelsForTrip; + } + } + + if ( + appConfig?.survey_info?.buttons?.['trip-notes'] || + appConfig?.survey_info?.buttons?.['place-notes'] + ) { + // trip-level or place-level notes are configured, so we need to match additions too + /* With additions/notes, we can have multiple entries for a single trip or place. + So, we will read both the processed additions and unprocessed additions + and merge them together, removing duplicates. */ + const nextEntry = i + 1 < allEntries.length ? allEntries[i + 1] : null; + const unprocessedAdditions = getAdditionsForTimelineEntry( + tlEntry, + nextEntry, + unprocessedNotes, + ); + const processedAdditions = tlEntry.additions || []; + + const mergedAdditions = getUniqueEntries( + getNotDeletedCandidates([...unprocessedAdditions, ...processedAdditions]), + ); + if (mergedAdditions?.length) { + timelineNotesMap[tlEntry._id.$oid] = mergedAdditions; + } + } + }); + + return [timelineLabelMap, timelineNotesMap]; +} + +function validBleScanForTimelineEntry(tlEntry: TimelineEntry, bleScan: BEMData) { + let entryStart = (tlEntry as CompositeTrip).start_ts || (tlEntry as ConfirmedPlace).enter_ts; + let entryEnd = (tlEntry as CompositeTrip).end_ts || (tlEntry as ConfirmedPlace).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 = DateTime.fromSeconds(entryEnd).startOf('day').toUnixInteger(); + } + + 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; + } + + return bleScan.data.ts >= entryStart && bleScan.data.ts <= entryEnd; +} + +/** + * @description Get BLE scans that are of type RANGE_UPDATE and are within the time range of the timeline entry + */ +function getBleRangingScansForTimelineEntry( + tlEntry: TimelineEntry, + bleScans: BEMData[], +) { + return bleScans.filter( + (scan) => + /* RANGE_UPDATE is the string value, but the server uses an enum, so once processed it becomes 2 */ + (scan.data.eventType == 'RANGE_UPDATE' || scan.data.eventType == 2) && + validBleScanForTimelineEntry(tlEntry, scan), + ); +} + +/** + * @description Convert a decimal number to a hexadecimal string, with optional padding + * @example decimalToHex(245) => 'f5' + * @example decimalToHex(245, 4) => '00f5' + */ +function decimalToHex(d: string | number, padding?: number) { + let hex = Number(d).toString(16); + while (hex.length < (padding || 0)) { + hex = '0' + hex; + } + return hex; +} + +export function mapBleScansToTimelineEntries(allEntries: TimelineEntry[], appConfig: AppConfig) { + const timelineBleMap = {}; + for (const tlEntry of allEntries) { + const rangingScans = getBleRangingScansForTimelineEntry(tlEntry, unprocessedBleScans); + if (!rangingScans.length) { + continue; + } + + // count the number of occurrences of each major:minor pair + const majorMinorCounts = {}; + rangingScans.forEach((scan) => { + const major = decimalToHex(scan.data.major, 4); + const minor = decimalToHex(scan.data.minor, 4); + const majorMinor = major + ':' + minor; + majorMinorCounts[majorMinor] = majorMinorCounts[majorMinor] + ? majorMinorCounts[majorMinor] + 1 + : 1; + }); + // determine the major:minor pair with the highest count + const match = Object.keys(majorMinorCounts).reduce((a, b) => + majorMinorCounts[a] > majorMinorCounts[b] ? a : b, + ); + // find the vehicle identity that uses this major:minor pair + const vehicleIdentity = appConfig.vehicle_identities?.find((vi) => + vi.bluetooth_major_minor.includes(match), + ); + if (vehicleIdentity) { + timelineBleMap[tlEntry._id.$oid] = vehicleIdentity; + } else { + displayErrorMsg(`No vehicle identity found for major:minor pair ${match}`); + } + } + return timelineBleMap; +} diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index ec56295eb..517223141 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -2,46 +2,71 @@ 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 { 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, { UserInputMap } from '../../diary/LabelTabContext'; +import { displayErrorMsg, logDebug } from '../../plugin/logger'; +import { + getLabelInputDetails, + getLabelInputs, + inferFinalLabels, + labelInputDetailsForTrip, + labelKeyToReadable, + labelKeyToRichMode, + readableLabelToKey, + verifiabilityForTrip, +} from './confirmHelper'; +import useAppConfig from '../../useAppConfig'; +import { MultilabelKey } from '../../types/labelTypes'; -const MultilabelButtonGroup = ({ trip, buttonsInline=false }) => { +const MultilabelButtonGroup = ({ trip, buttonsInline = false }) => { const { colors } = useTheme(); const { t } = useTranslation(); - const { repopulateTimelineEntry, labelOptions } = useContext(LabelTabContext); + const appConfig = useAppConfig(); + const { labelOptions, labelFor, userInputFor, addUserInputToEntry } = 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 chosenLabel = useMemo(() => { + // modal visible for which input type? (MODE or PURPOSE or REPLACED_MODE, null if not visible) + const [modalVisibleFor, setModalVisibleFor] = useState(null); + const [otherLabel, setOtherLabel] = useState(null); + const chosenLabel = useMemo(() => { + if (modalVisibleFor == null) return null; if (otherLabel != null) return 'other'; - return trip.userInput[modalVisibleFor]?.value + return labelFor(trip, modalVisibleFor)?.value || null; }, [modalVisibleFor, otherLabel]); // to mark 'inferred' labels as 'confirmed'; turn yellow labels blue function verifyTrip() { + const inferredLabelsForTrip = inferFinalLabels(trip, userInputFor(trip)); + let labelsToStore; for (const inputType of getLabelInputs()) { - const inferred = trip.finalInference[inputType]; - if (inferred?.value && !trip.userInput[inputType]) { - store(inputType, inferred.value, false); + const inferred = inferredLabelsForTrip?.[inputType]; + // if there is an inferred label that is not already confirmed, confirm it by storing it + if (inferred?.value && !labelFor(trip, inputType)) { + labelsToStore = { ...labelsToStore, [inputType]: inferred.value }; } } + if (labelsToStore) store(labelsToStore); } function onChooseLabel(chosenValue) { + if (!modalVisibleFor) return displayErrorMsg('Cannot choose label when modal not visible'); logDebug(`onChooseLabel with chosen ${modalVisibleFor} as ${chosenValue}`); if (chosenValue == 'other') { setOtherLabel(''); } else { - store(modalVisibleFor, chosenValue, false); + store({ [modalVisibleFor]: chosenValue }); } } @@ -50,93 +75,129 @@ const MultilabelButtonGroup = ({ trip, buttonsInline=false }) => { setOtherLabel(null); } - function store(inputType, chosenLabel, isOther) { - 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, - }; + /* Store a batch of one or more inputs to the user cache, dismiss the popup if it was visible, + and inform LabelTab of new inputs */ + function store(inputs: { [k in MultilabelKey]?: string }, isOther?) { + if (!Object.keys(inputs).length) return displayErrorMsg('No inputs to store'); + const inputsToStore: UserInputMap = {}; + const storePromises: any[] = []; + for (let [inputType, chosenLabel] of Object.entries(inputs)) { + 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, + }; + inputsToStore[inputType] = inputDataToStore; - const storageKey = getLabelInputDetails()[inputType].key; - window['cordova'].plugins.BEMUserCache.putMessage(storageKey, inputDataToStore).then(() => { + const storageKey = getLabelInputDetails()[inputType].key; + storePromises.push( + window['cordova'].plugins.BEMUserCache.putMessage(storageKey, inputDataToStore), + ); + } + Promise.all(storePromises).then(() => { + logDebug('Successfully stored input data ' + JSON.stringify(inputsToStore)); dismiss(); - repopulateTimelineEntry(trip._id.$oid); - logDebug("Successfully stored input data "+JSON.stringify(inputDataToStore)); + addUserInputToEntry(trip._id.$oid, inputsToStore, 'label'); }); } - 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; + const tripInputDetails = labelInputDetailsForTrip(userInputFor(trip), appConfig); + return ( + <> + + + {Object.keys(tripInputDetails).map((key, i) => { + const input = tripInputDetails[key]; + const inputIsConfirmed = labelFor(trip, input.name); + const inputIsInferred = inferFinalLabels(trip, userInputFor(trip))[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) } - - - ) - })} - - - + return ( + + {t(input.labeltext)} + setModalVisibleFor(input.name)}> + {t(btnText)} + + + ); + })} + + {verifiabilityForTrip(trip, userInputFor(trip)) == '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)} /> - - - - } - - - - ); + 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 || ''}> + {modalVisibleFor && + labelOptions?.[modalVisibleFor]?.map((o, i) => ( + + ))} + + + + {otherLabel != null && modalVisibleFor != null && ( + <> + setOtherLabel(t)} + /> + + + + + )} + + + + + ); }; export default MultilabelButtonGroup; diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index 6350745eb..f94032f3e 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -1,82 +1,70 @@ -// 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"; - -type InputDetails = { - [k in T]?: { - name: string, - labeltext: string, - choosetext: string, - key: string, - } -}; -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 } -}}; +import { fetchUrlCached } from '../../services/commHelper'; +import i18next from 'i18next'; +import enJson from '../../../i18n/en.json'; +import { logDebug } from '../../plugin/logger'; +import { LabelOption, LabelOptions, MultilabelKey, InputDetails } from '../../types/labelTypes'; +import { CompositeTrip, InferredLabels, TimelineEntry } from '../../types/diaryTypes'; +import { TimelineLabelMap, UserInputMap } from '../../diary/LabelTabContext'; let appConfig; -export let labelOptions: LabelOptions<'MODE'|'PURPOSE'|'REPLACED_MODE'>; -export let inputDetails: InputDetails<'MODE'|'PURPOSE'|'REPLACED_MODE'>; +export let labelOptions: LabelOptions; +export let inputDetails: InputDetails; export async function getLabelOptions(appConfigParam?) { if (appConfigParam) appConfig = appConfigParam; if (labelOptions) return labelOptions; - if (appConfig.label_options) { const labelOptionsJson = await fetchUrlCached(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; - }); + if (labelOptionsJson) { + logDebug(`label_options found in config, using dynamic label options + at ${appConfig.label_options}`); + labelOptions = JSON.parse(labelOptionsJson) as LabelOptions; } } 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); + if (defaultLabelOptionsJson) { + 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.resolvedLanguage || 'en'; + for (const opt in labelOptions) { + labelOptions[opt]?.forEach?.((o, i) => { + const translationKey = o.value; + /* If translation exists in labelOptions, use that. Otherwise, try i18next translations. */ + const translationFromLabelOptions = labelOptions.translations?.[lang]?.[translationKey]; + if (translationFromLabelOptions) { + labelOptions[opt][i].text = translationFromLabelOptions; + } else { + const i18nextKey = translationKey as keyof typeof enJson.multilabel; // cast for type safety + labelOptions[opt][i].text = i18next.t(`multilabel.${i18nextKey}`); + } + }); + } return labelOptions; } +export const labelOptionByValue = (value: string, labelType: string): LabelOption | undefined => + labelOptions[labelType]?.find((o) => o.value == value) || getFakeEntry(value); + 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; @@ -88,37 +76,134 @@ 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; } -export const getLabelInputs = () => Object.keys(getLabelInputDetails()); -export const getBaseLabelInputs = () => Object.keys(baseLabelInputDetails); +export function labelInputDetailsForTrip(userInputForTrip, appConfigParam?) { + if (appConfigParam) appConfig = appConfigParam; + if (appConfig.intro.mode_studied) { + if (userInputForTrip?.['MODE']?.data?.label == appConfig.intro.mode_studied) { + logDebug(`Found trip labeled with ${userInputForTrip?.['MODE']?.data?.label}, mode of study = ${appConfig.intro.mode_studied}. + Needs REPLACED_MODE`); + return getLabelInputDetails(); + } else { + logDebug(`Found trip labeled with ${userInputForTrip?.['MODE']?.data?.label}, not labeled with mode of study = ${appConfig.intro.mode_studied}. + Doesn't need REPLACED_MODE`); + return baseLabelInputDetails; + } + } else { + logDebug('No mode of study, so there is no REPLACED_MODE label option'); + return getLabelInputDetails(); + } +} + +export const getLabelInputs = () => Object.keys(getLabelInputDetails()) as MultilabelKey[]; +export const getBaseLabelInputs = () => Object.keys(baseLabelInputDetails) as MultilabelKey[]; /** @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(" "); +export function 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(' '); } /** @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), - value: otherValue, -}); +export function getFakeEntry(otherValue): Partial | undefined { + if (!otherValue) return undefined; + return { + text: labelKeyToReadable(otherValue), + value: otherValue, + }; +} export const labelKeyToRichMode = (labelKey: string) => - labelOptions?.MODE?.find(m => m.value == labelKey)?.text || labelKeyToReadable(labelKey); + labelOptionByValue(labelKey, 'MODE')?.text || labelKeyToReadable(labelKey); + +/** @description e.g. manual/mode_confirm becomes mode_confirm */ +export const removeManualPrefix = (key: string) => key.split('/')[1]; +/** @description e.g. 'MODE' gets looked up, its key is 'manual/mode_confirm'. Returns without prefix as 'mode_confirm' */ +export const inputType2retKey = (inputType: string) => + removeManualPrefix(getLabelInputDetails()[inputType].key); + +export function verifiabilityForTrip(trip: CompositeTrip, userInputForTrip?: UserInputMap) { + let allConfirmed = true; + let someInferred = false; + const inputsForTrip = Object.keys(labelInputDetailsForTrip(userInputForTrip)); + for (const inputType of inputsForTrip) { + const finalInference = inferFinalLabels(trip, userInputForTrip)[inputType]; + const confirmed = userInputForTrip?.[inputType]; + const inferred = finalInference && Object.values(finalInference).some((o) => o); + if (inferred && !confirmed) someInferred = true; + if (!confirmed) allConfirmed = false; + } + return someInferred ? 'can-verify' : allConfirmed ? 'already-verified' : 'cannot-verify'; +} + +export function inferFinalLabels(trip: CompositeTrip, userInputForTrip?: UserInputMap) { + // Deep copy the possibility tuples + let labelsList: InferredLabels = []; + if (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 = userInputForTrip?.[inputType]; + if (userInput) { + const retKey = inputType2retKey(inputType); + labelsList = labelsList.filter((item) => item.labels[retKey] == userInput.data.label); + } + } + + const finalInference: { [k in MultilabelKey]?: LabelOption } = {}; + + // Return early with (empty obj) if there are no possibilities left + if (labelsList.length == 0) { + return finalInference; + } 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 = 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; + + if (max.labelValue) { + finalInference[inputType] = labelOptionByValue(max.labelValue, inputType); + } + } + return finalInference; + } +} diff --git a/www/js/survey/multilabel/infinite_scroll_filters.js b/www/js/survey/multilabel/infinite_scroll_filters.js deleted file mode 100644 index bc588ecc2..000000000 --- a/www/js/survey/multilabel/infinite_scroll_filters.js +++ /dev/null @@ -1,67 +0,0 @@ -'use strict'; - -/* - * The general structure of this code is that all the timeline information for - * a particular day is retrieved from the Timeline factory and put into the scope. - * For best performance, all data should be loaded into the in-memory timeline, - * and in addition to writing to storage, the data should be written to memory. - * All UI elements should only use $scope variables. - */ - -import angular from 'angular'; - -angular.module('emission.survey.multilabel.infscrollfilters',[ - 'emission.plugin.logger' - ]) -.factory('MultiLabelInfScrollFilters', function(Logger){ - var sf = {}; - var unlabeledCheck = function(t) { - return t.INPUTS - .map((inputType, index) => !angular.isDefined(t.userInput[inputType])) - .reduce((acc, val) => acc || val, false); - } - - var invalidCheck = function(t) { - const retVal = - (angular.isDefined(t.userInput['MODE']) && t.userInput['MODE'].value === 'pilot_ebike') && - (!angular.isDefined(t.userInput['REPLACED_MODE']) || - t.userInput['REPLACED_MODE'].value === 'pilot_ebike' || - t.userInput['REPLACED_MODE'].value === 'same_mode'); - return retVal; - } - - var toLabelCheck = function(trip) { - if (angular.isDefined(trip.expectation)) { - console.log(trip.expectation.to_label) - return trip.expectation.to_label && unlabeledCheck(trip); - } else { - return true; - } - } - - sf.UNLABELED = { - key: "unlabeled", - text: i18next.t("diary.unlabeled"), - filter: unlabeledCheck, - width: "col-50" - } - - sf.INVALID_EBIKE = { - key: "invalid_ebike", - text: i18next.t("diary.invalid-ebike"), - filter: invalidCheck - } - - sf.TO_LABEL = { - key: "to_label", - text: i18next.t("diary.to-label"), - filter: toLabelCheck, - width: "col-50" - } - - sf.configuredFilters = [ - sf.TO_LABEL, - sf.UNLABELED - ]; - return sf; -}); diff --git a/www/js/survey/multilabel/infinite_scroll_filters.ts b/www/js/survey/multilabel/infinite_scroll_filters.ts new file mode 100644 index 000000000..187b56b2c --- /dev/null +++ b/www/js/survey/multilabel/infinite_scroll_filters.ts @@ -0,0 +1,38 @@ +/* + * The general structure of this code is that all the timeline information for + * a particular day is retrieved from the Timeline factory and put into the scope. + * For best performance, all data should be loaded into the in-memory timeline, + * and in addition to writing to storage, the data should be written to memory. + * All UI elements should only use $scope variables. + */ + +import i18next from 'i18next'; +import { labelInputDetailsForTrip } from './confirmHelper'; +import { logDebug } from '../../plugin/logger'; + +function unlabeledCheck(trip, userInputForTrip) { + const tripInputDetails = labelInputDetailsForTrip(userInputForTrip); + return Object.keys(tripInputDetails) + .map((inputType) => !userInputForTrip?.[inputType]) + .reduce((acc, val) => acc || val, false); +} + +function toLabelCheck(trip, userInputForTrip) { + logDebug('Expectation: ' + trip.expectation); + if (!trip.expectation) return true; + return trip.expectation.to_label && unlabeledCheck(trip, userInputForTrip); +} + +const UNLABELED = { + key: 'unlabeled', + text: i18next.t('diary.unlabeled'), + filter: unlabeledCheck, +}; + +const TO_LABEL = { + key: 'to_label', + text: i18next.t('diary.to-label'), + filter: toLabelCheck, +}; + +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 deleted file mode 100644 index 313c8a3a9..000000000 --- a/www/js/survey/multilabel/multi-label-ui.js +++ /dev/null @@ -1,206 +0,0 @@ -import angular from 'angular'; -import { baseLabelInputDetails, getBaseLabelInputs, getFakeEntry, getLabelInputDetails, getLabelInputs, getLabelOptions } from './confirmHelper'; -import { getConfig } from '../../config/dynamicConfig'; - -angular.module('emission.survey.multilabel.buttons', - ['emission.stats.clientstats', - '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; - }); - } - - 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); - } - 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"); - 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; - } - 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 deleted file mode 100644 index e6692983f..000000000 --- a/www/js/survey/survey.ts +++ /dev/null @@ -1,13 +0,0 @@ -type SurveyOption = { filter: string, service: string, elementTag: string } -export const SurveyOptions: {[key: string]: SurveyOption} = { - MULTILABEL: { - filter: "MultiLabelInfScrollFilters", - service: "MultiLabelService", - elementTag: "multilabel" - }, - ENKETO: { - filter: "EnketoTripInfScrollFilters", - service: "EnketoTripButtonService", - elementTag: "enketo-trip-button" - } -} diff --git a/www/js/types/BluetoothDevices.ts b/www/js/types/BluetoothDevices.ts new file mode 100644 index 000000000..c29f55740 --- /dev/null +++ b/www/js/types/BluetoothDevices.ts @@ -0,0 +1,39 @@ +// Device data, as defined in BluetoothClassicSerial's docs +export type BluetoothClassicDevice = { + class: number; + id: string; + address: string; + name: string; + is_paired?: boolean; // We keep track of this, because BCS doesn't +}; + +/* Config File containg BLEBeaconData, mapped in the format + * UID_KEY: {Device_Info} + * + * This is set up for how a JSON file would store this data; we + * will most likely change this later on! + */ + +export type BLEBeaconDevice = { + identifier: string; + uuid: string; + major: number; + minor: number; + type_name?: string; // e.g., "BeaconRegion"; used for callback +}; +export type BLEDeviceList = { + [key: string]: { + identifier: string; + minor: number; + major: number; + monitorResult: string; + rangeResult: string; + in_range: boolean; + }; +}; + +export type BLEPluginCallback = { + region: BLEBeaconDevice; + eventType: string; + state: string; +}; diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts new file mode 100644 index 000000000..d5a15fe4a --- /dev/null +++ b/www/js/types/appConfigTypes.ts @@ -0,0 +1,94 @@ +// WIP: type definitions for the 'dynamic config' spec +// examples of configs: https://github.com/e-mission/nrel-openpath-deploy-configs/tree/main/configs + +export type AppConfig = { + version: number; + server: ServerConnConfig; + intro: IntroConfig; + survey_info: { + 'trip-labels': 'MULTILABEL' | 'ENKETO'; + surveys: EnketoSurveyConfig; + buttons?: SurveyButtonsConfig; + }; + vehicle_identities?: VehicleIdentity[]; + tracking?: { + bluetooth_only: boolean; + }; + reminderSchemes?: ReminderSchemesConfig; + [k: string]: any; // TODO fill in all the other fields +}; + +export type ServerConnConfig = { + connectUrl: `https://${string}`; + aggregate_call_auth: 'no_auth' | 'user_only' | 'never'; +}; + +export type IntroConfig = { + program_or_study: 'program' | 'study'; + app_required: boolean; + start_month: number; + start_year: number; + mode_studied?: string; + program_admin_contact: string; + deployment_partner_name: string; + translated_text: { + [lang: string]: { + [key: string]: string; + }; + }; +}; + +export type EnketoSurveyConfig = { + [surveyName: string]: { + formPath: string; + labelTemplate: { [lang: string]: string }; + labelVars?: { [activity: string]: { [key: string]: string; type: string } }; + version: number; + compatibleWith: number; + dataKey?: string; + }; +}; + +export type SurveyButtonConfig = { + surveyName: string; + 'not-filled-in-label': { + [lang: string]: string; + }; + showsIf: string; // a JS expression that evaluates to a boolean +}; +export type SurveyButtonsConfig = { + [k in 'trip-label' | 'trip-notes' | 'place-label' | 'place-notes']: + | SurveyButtonConfig + | SurveyButtonConfig[]; +}; + +export type VehicleIdentity = { + value: string; + bluetooth_major_minor: string[]; // e.g. ['aaaa:bbbb', 'cccc:dddd'] + text: string; + baseMode: string; + met_equivalent: string; + kgCo2PerKm: number; + vehicle_info: { + type: string; + license: string; + make: string; + model: string; + year: number; + color: string; + engine: 'ICE' | 'HEV' | 'PHEV' | 'BEV' | 'HYDROGENV' | 'BIOV'; + }; +}; + +export type ReminderSchemesConfig = { + [schemeKey: string]: { + title: { [lang: string]: string }; + text: { [lang: string]: string }; + schedule: { + start: number; + end?: number; + intervalInDays: number; + }[]; + defaultTime?: string; // format is HH:MM in 24 hour time + }; +}; diff --git a/www/js/types/diaryTypes.ts b/www/js/types/diaryTypes.ts new file mode 100644 index 000000000..9757e95cf --- /dev/null +++ b/www/js/types/diaryTypes.ts @@ -0,0 +1,237 @@ +/* This file provides typings for use in '/diary', including timeline objects (trips and places) + and user input objects. + As much as possible, these types parallel the types used in the server code. */ + +import { BaseModeKey, MotionTypeKey } from '../diary/diaryHelper'; +import useDerivedProperties from '../diary/useDerivedProperties'; +import { MultilabelKey } from './labelTypes'; +import { BEMData, LocalDt } from './serverData'; +import { FeatureCollection, Feature, Geometry, Point, Position } from 'geojson'; + +type ObjectId = { $oid: string }; + +type UserInput = { + /* for keys ending in 'user_input' (e.g. 'trip_user_input'), the server gives us the raw user + input object with 'data' and 'metadata' */ + [k: `${string}user_input`]: UserInputEntry; + /* for keys ending in 'confirm' (e.g. 'mode_confirm'), the server just gives us the user input value + as a string (e.g. 'walk', 'drove_alone') */ + [k: `${string}confirm`]: string; +}; + +export type ConfirmedPlace = { + _id: ObjectId; + additions: UserInputEntry[]; + cleaned_place: ObjectId; + duration: number; + ending_trip: ObjectId; + enter_fmt_time: string; // ISO string e.g. 2023-10-31T12:00:00.000-04:00 + enter_local_dt: LocalDt; + enter_ts: number; // Unix timestamp + exit_fmt_time: string; // ISO string e.g. 2023-10-31T12:00:00.000-04:00 + exit_local_dt: LocalDt; + exit_ts: number; // Unix timestamp + key: string; + location: Geometry; + origin_key: string; + raw_places: ObjectId[]; + source: string; + user_input: UserInput; + starting_trip: ObjectId; +}; + +export type TripTransition = { + currstate: string; + transition: string | number; + ts: number; +}; + +export type CompositeTripLocation = { + loc: { + coordinates: Position; // [lon, lat] + }; + speed: number; + ts: number; +}; + +// Used for return type of readUnprocessedTrips +export type UnprocessedTrip = { + _id: ObjectId; + additions: []; // unprocessed trips won't have any matched processed inputs, so this is always empty + confidence_threshold: number; + distance: number; + duration: number; + end_fmt_time: string; + end_loc: Point; + end_local_dt: LocalDt; + end_ts: number; + expectation: { to_label: true }; // unprocessed trips are always expected to be labeled + inferred_labels: []; // unprocessed trips won't have inferred labels + key: 'UNPROCESSED_trip'; + locations?: CompositeTripLocation[]; + origin_key: 'UNPROCESSED_trip'; + sections: SectionData[]; + source: 'unprocessed'; + start_fmt_time: string; + start_local_dt: LocalDt; + start_ts: number; + start_loc: Point; + starting_trip?: any; + user_input: {}; // unprocessed trips won't have any matched processed inputs, so this is always empty +}; + +/* 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: ObjectId; + additions: UserInputEntry[]; + cleaned_section_summary: SectionSummary; + cleaned_trip: ObjectId; + confidence_threshold: number; + confirmed_trip: ObjectId; + distance: number; + duration: number; + end_confirmed_place: BEMData; + end_fmt_time: string; + end_loc: Point; + end_local_dt: LocalDt; + end_place: ObjectId; + end_ts: number; + expectation: { to_label: boolean }; + expected_trip: ObjectId; + inferred_labels: InferredLabels; + inferred_section_summary: SectionSummary; + inferred_trip: ObjectId; + key: string; + locations: CompositeTripLocation[]; + origin_key: string; + raw_trip: ObjectId; + sections: SectionData[]; + source: string; + start_confirmed_place: BEMData; + start_fmt_time: string; + start_loc: Point; + start_local_dt: LocalDt; + start_place: ObjectId; + start_ts: number; + user_input: UserInput; +}; + +/* The 'timeline' for a user is a list of their trips and places, + so a 'timeline entry' is either a trip or a place. */ +export type TimelineEntry = ConfirmedPlace | CompositeTrip; + +/* Type guard to disambiguate timeline entries as either trips or places + If it has a 'start_ts' and 'end_ts', it's a trip. Else, it's a place. */ +export const isTrip = (entry: TimelineEntry): entry is CompositeTrip => + entry.hasOwnProperty('start_ts') && entry.hasOwnProperty('end_ts'); + +export type TimestampRange = { start_ts: number; end_ts: number }; + +/* 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 = ReturnType; + +export type SectionSummary = { + count: { [k: MotionTypeKey | BaseModeKey]: number }; + distance: { [k: MotionTypeKey | BaseModeKey]: number }; + duration: { [k: MotionTypeKey | BaseModeKey]: number }; +}; + +export type InferredLabels = { + p: number; + labels: { [k in Lowercase as `${k}_confirm`]?: string }; +}[]; + +export type UserInputData = { + end_ts: number; + start_ts: number; + label: string; + start_local_dt?: LocalDt; + end_local_dt?: LocalDt; + status?: string; + match_id?: string; + name: string; +}; +export type UserInputEntry = { + data: T; + metadata: { + time_zone: string; + plugin: string; + write_ts: number; + platform: string; + read_ts: number; + key: string; + }; + key?: string; +}; + +export type BluetoothBleData = { + ts: number; + eventType: 'REGION_ENTER' | 'REGION_EXIT' | 'RANGE_UPDATE' | number; + uuid: string; + major: number; // for our use case, missing for REGION_ENTER or REGION_EXIT + minor: number; // for our use case, missing for REGION_ENTER or REGION_EXIT + proximity?: string; // only available for RANGE_UPDATE + rssi?: string; // only available for RANGE_UPDATE + accuracy?: string; // only available for RANGE_UPDATE +}; + +export type Location = { + speed: number; + heading: number; + local_dt: LocalDt; + idx: number; + section: ObjectId; + longitude: number; + latitude: number; + fmt_time: string; // ISO + mode: number; + loc: Point; + ts: number; // Unix + altitude: number; + distance: number; +}; + +export type SectionData = { + _id: ObjectId; + end_ts: number; // Unix time, e.x. 1696352498.804 + end_loc: Point; + start_fmt_time: string; // ISO time + end_fmt_time: string; + key: string; + origin_key: string; + trip_id: ObjectId; + sensed_mode: number; + source: string; // e.x., "SmoothedHighConfidenceMotion" + start_ts: number; // Unix + start_loc: Point; + cleaned_section: ObjectId; + start_local_dt: LocalDt; + end_local_dt: LocalDt; + sensed_mode_str: string; //e.x., "CAR" + duration: number; + distance: number; +}; + +// used in timelineHelper's `transitionTrip2UnprocessedTrip` +export type FilteredLocation = { + accuracy: number; + altitude: number; + elapsedRealtimeNanos: number; + filter: string; + fmt_time: string; + heading: number; + latitude: number; + loc: Geometry; + local_dt: LocalDt; + longitude: number; + sensed_speed: number; + ts: number; +}; + +export type GeoJSONStyledFeature = Feature & { style?: { color: string } }; + +export type GeoJSONData = { + data: FeatureCollection & { id: string; properties: { start_ts: number; end_ts: number } }; +}; diff --git a/www/js/types/dynamicStyleSheet.d.ts b/www/js/types/dynamicStyleSheet.d.ts new file mode 100644 index 000000000..68be967ef --- /dev/null +++ b/www/js/types/dynamicStyleSheet.d.ts @@ -0,0 +1,18 @@ +/* + Enables "dynamic" styles in StyleSheet.create. + Explanation: https://github.com/e-mission/e-mission-phone/pull/1106/files#r1475128446 +*/ + +import 'react-native'; +import { TextStyle, ViewStyle, ImageStyle } from 'react-native'; + +type Style = ViewStyle | TextStyle | ImageStyle; + +declare module 'react-native' { + namespace StyleSheet { + type NamedDynamicStyles = { [P in keyof T]: Style | ((...args: any[]) => Style) }; + export function create | NamedDynamicStyles>( + styles: T | NamedDynamicStyles, + ): T; + } +} diff --git a/www/js/types/fileShareTypes.ts b/www/js/types/fileShareTypes.ts new file mode 100644 index 000000000..ee8d9a14e --- /dev/null +++ b/www/js/types/fileShareTypes.ts @@ -0,0 +1,22 @@ +import { BEMData } from './serverData'; + +export type TimeStampData = BEMData; + +export type RawTimelineData = { + name: string; + ts: number; + reading: number; +}; + +export interface FsWindow extends Window { + requestFileSystem: ( + type: number, + size: number, + successCallback: (fs: any) => void, + errorCallback?: (error: any) => void, + ) => void; + LocalFileSystem: { + TEMPORARY: number; + PERSISTENT: number; + }; +} diff --git a/www/js/types/i18next.d.ts b/www/js/types/i18next.d.ts new file mode 100644 index 000000000..efe6715c9 --- /dev/null +++ b/www/js/types/i18next.d.ts @@ -0,0 +1,7 @@ +import enJson from '../../i18n/en.json'; + +declare module 'i18next' { + interface CustomTypeOptions { + resources: { translation: typeof enJson }; + } +} diff --git a/www/js/types/labelTypes.ts b/www/js/types/labelTypes.ts new file mode 100644 index 000000000..8ac720adc --- /dev/null +++ b/www/js/types/labelTypes.ts @@ -0,0 +1,24 @@ +export type InputDetails = { + [k in T]?: { + name: string; + labeltext: string; + choosetext: string; + key: string; + }; +}; +export type LabelOption = { + value: string; + baseMode: string; + met?: { range: any[]; mets: number }; + met_equivalent?: string; + kgCo2PerKm: number; + text?: string; +}; +export type MultilabelKey = 'MODE' | 'PURPOSE' | 'REPLACED_MODE'; +export type LabelOptions = { + [k in T]: LabelOption[]; +} & { + translations: { + [lang: string]: { [translationKey: string]: string }; + }; +}; diff --git a/www/js/types/serverData.ts b/www/js/types/serverData.ts new file mode 100644 index 000000000..b68078552 --- /dev/null +++ b/www/js/types/serverData.ts @@ -0,0 +1,47 @@ +export type ServerResponse = { + phone_data: Array>; +}; + +export type BEMData = { + data: Type; + metadata: MetaData; + key?: string; + user_id?: { $uuid: string }; + _id?: { $oid: string }; +}; + +export type MetaData = { + key: string; + platform: string; + write_ts: number; + time_zone: string; + write_fmt_time: string; + write_local_dt: LocalDt; + origin_key?: string; + read_ts?: number; +}; + +export type LocalDt = { + minute: number; + hour: number; + second: number; + day: number; + weekday: number; + month: number; + year: number; + timezone: string; +}; + +/* + * The server also supports queries via TimeQueryComponents, which can be split into multiple + * dates. The TimeQuery type was designed for UserCache calls, which only query via the + * `write_ts` time. For more details, please see the following files in /e-mission-server/: + * - /emission/storage/timeseries/tcquery.py : additional timeQueryComponent + * - /emission/storage/timeseries/timeQuery.py : timeQuery object used for `write_ts` queries + * - /emission/net/api/cfc_webapp.py : implementation of `/datastreams/find_enteries/` + */ +export type TimeQuery = { + key: string; + startTs: number; + endTs: number; +}; diff --git a/www/js/useAppConfig.ts b/www/js/useAppConfig.ts index 633069326..baeb41358 100644 --- a/www/js/useAppConfig.ts +++ b/www/js/useAppConfig.ts @@ -1,24 +1,30 @@ -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 { configChanged, getConfig, setConfigChanged } from './config/dynamicConfig'; +import { logDebug } from './plugin/logger'; +import { AppConfig } from './types/appConfigTypes'; -const useAppConfig = () => { +/* For Cordova, 'deviceready' means that Cordova plugins are loaded and ready to access. + https://cordova.apache.org/docs/en/5.0.0/cordova/events/events.deviceready.html + We wrap this event in a promise and await it before attempting to update the config, + since loading the config requires accessing native storage through plugins. */ +const deviceReady = new Promise((resolve) => { + document.addEventListener('deviceready', resolve); +}); - const [appConfig, setAppConfig] = useState(null); - const $ionicPlatform = getAngularService('$ionicPlatform'); +const useAppConfig = () => { + const [appConfig, setAppConfig] = useState(null as any); useEffect(() => { - $ionicPlatform.ready().then(updateConfig); + deviceReady.then(updateConfig); }, []); function updateConfig() { return getConfig().then((config) => { - if (Object.keys(config).length) { + if (config && Object.keys(config).length) { setAppConfig(config); } else { logDebug('Config was empty, treating as null'); - setAppConfig(null); + setAppConfig(null as any); } }); } @@ -27,6 +33,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..9a77909a0 100644 --- a/www/js/useAppStateChange.ts +++ b/www/js/useAppStateChange.ts @@ -5,25 +5,23 @@ import { useEffect, useRef } from 'react'; import { AppState } from 'react-native'; +import { logDebug } from './plugin/logger'; 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; + logDebug('new AppState: ' + appState.current); + }); + }, []); + + return {}; +}; + +export default useAppStateChange; diff --git a/www/js/usePermissionStatus.ts b/www/js/usePermissionStatus.ts index 035ba6b16..81c4abf54 100644 --- a/www/js/usePermissionStatus.ts +++ b/www/js/usePermissionStatus.ts @@ -1,352 +1,487 @@ import { useEffect, useState, useMemo } from 'react'; -import useAppStateChange from "./useAppStateChange"; -import useAppConfig from "./useAppConfig"; -import { useTheme } from 'react-native-paper'; -import { useTranslation } from "react-i18next"; +import useAppStateChange from './useAppStateChange'; +import useAppConfig from './useAppConfig'; +import { useTranslation } from 'react-i18next'; +import { useAppTheme } from './appTheme'; +import { logDebug, logWarn } from './plugin/logger'; //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(); + }); + logDebug('After refreshing all checks, checks are ' + JSON.stringify(checkList)); } +type Check = { + name: string; + desc: string; + fix: () => Promise; + refresh: () => Promise; + statusState?: boolean; + statusIcon?: string; + statusColor?: string; +}; + const usePermissionStatus = () => { + const { t } = useTranslation(); + const { colors } = useAppTheme(); + const appConfig = useAppConfig(); - const { t } = useTranslation(); - const { colors } = useTheme(); - const appConfig = useAppConfig(); + const [error, setError] = useState(''); + const [errorVis, setErrorVis] = useState(false); - const [error, setError] = useState(""); - const [errorVis, setErrorVis] = useState(false); + const [checkList, setCheckList] = useState([]); + const [explanationList, setExplanationList] = useState>([]); + const [haveSetText, setHaveSetText] = useState(false); - const [checkList, setCheckList] = useState([]); - const [explanationList, setExplanationList] = useState>([]); - const [haveSetText, setHaveSetText] = useState(false); + let iconMap = (statusState) => (statusState ? 'check-circle-outline' : 'alpha-x-circle-outline'); + let colorMap = (statusState) => (statusState ? colors.success : colors.danger); - let iconMap = (statusState) => statusState ? "check-circle-outline" : "alpha-x-circle-outline"; - let colorMap = (statusState) => statusState ? colors.success : colors.danger; + const overallStatus = useMemo(() => { + let status = true; + if (!checkList?.length) return undefined; // if checks not loaded yet, status is undetermined + checkList.forEach((lc) => { + logDebug('check in permission status for ' + lc.name + ':' + lc.statusState); + if (lc.statusState === false) { + status = false; + } + }); + return status; + }, [checkList]); - 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; - } - }) + //using this function to update checks rather than mutate + //this cues React to update UI + function updateCheck(newObject) { + const 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); + } + + async function checkOrFix(checkObj, nativeFn, showError = true) { + logDebug('checking object ' + checkObj.name + ' ' + JSON.stringify(checkObj)); + let newCheck = checkObj; + return nativeFn() + .then((status) => { + logDebug('availability = ' + status); + newCheck.statusState = true; + updateCheck(newCheck); + logDebug(`after checking obj ${checkObj.name}, checkList is ${JSON.stringify(checkList)}`); return status; - }, [checkList]) + }) + .catch((error) => { + if (showError) { + setError(error); + setErrorVis(true); + } + newCheck.statusState = false; + updateCheck(newCheck); + logDebug(`after checking obj ${checkObj.name}, checkList is ${JSON.stringify(checkList)}`); + return error; + }); + } + + function setupAndroidLocChecks() { + let fixSettings = () => { + logDebug('Fix and refresh location settings'); + return checkOrFix( + locSettingsCheck, + window['cordova'].plugins.BEMDataCollection.fixLocationSettings, + true, + ); + }; + let checkSettings = () => { + logDebug('Refresh location settings'); + return checkOrFix( + locSettingsCheck, + window['cordova'].plugins.BEMDataCollection.isValidLocationSettings, + false, + ); + }; + let fixPerms = () => { + logDebug('fix and refresh location permissions'); + return checkOrFix( + locPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.fixLocationPermissions, + true, + ).then((error) => { + if (error) { + locPermissionsCheck.desc = error; + } + }); + }; + let checkPerms = () => { + logDebug('fix and refresh location permissions'); + return checkOrFix( + locPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.isValidLocationPermissions, + false, + ); + }; + const androidVersion = window['device'].version.split('.')[0]; + const androidSettingsDescTag = + androidVersion < 9 + ? 'intro.appstatus.locsettings.description.android-lt-9' + : 'intro.appstatus.locsettings.description.android-gte-9'; + const androidPermDescTag = + androidVersion < 6 + ? 'intro.appstatus.locperms.description.android-lt-6' + : androidVersion < 10 + ? 'intro.appstatus.locperms.description.android-6-9' + : androidVersion < 11 + ? 'intro.appstatus.locperms.description.android-10' + : androidVersion < 12 + ? 'intro.appstatus.locperms.description.android-11' + : 'intro.appstatus.locperms.description.android-gte-12'; + logDebug('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); + } - //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; - } + function setupIOSLocChecks() { + let fixSettings = () => { + logDebug('Fix and refresh location settings'); + return checkOrFix( + locSettingsCheck, + window['cordova'].plugins.BEMDataCollection.fixLocationSettings, + true, + ); + }; + let checkSettings = () => { + logDebug('Refresh location settings'); + return checkOrFix( + locSettingsCheck, + window['cordova'].plugins.BEMDataCollection.isValidLocationSettings, + false, + ); + }; + let fixPerms = () => { + logDebug('fix and refresh location permissions'); + return checkOrFix( + locPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.fixLocationPermissions, + true, + ).then((error) => { + if (error) { + locPermissionsCheck.desc = error; + } + }); + }; + let checkPerms = () => { + logDebug('fix and refresh location permissions'); + return checkOrFix( + locPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.isValidLocationPermissions, + false, + ); + }; + const iOSVersion = window['device'].version.split('.')[0]; + const iOSSettingsDescTag = 'intro.appstatus.locsettings.description.ios'; + const iOSPermDescTag = + iOSVersion < 13 + ? 'intro.appstatus.locperms.description.ios-lt-13' + : 'intro.appstatus.locperms.description.ios-gte-13'; + logDebug('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); + } + + function setupAndroidFitnessChecks() { + if (window['device'].version.split('.')[0] >= 10) { + let fixPerms = () => { + logDebug('fix and refresh fitness permissions'); + return checkOrFix( + fitnessPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.fixFitnessPermissions, + true, + ).then((error) => { + if (error) { + fitnessPermissionsCheck.desc = error; + } }); - setCheckList(tempList); - } + }; + let checkPerms = () => { + logDebug('fix and refresh fitness permissions'); + return checkOrFix( + fitnessPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.isValidFitnessPermissions, + 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; - }).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; - }); + 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 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"; + function setupIOSFitnessChecks() { + let fixPerms = () => { + logDebug('fix and refresh fitness permissions'); + return checkOrFix( + fitnessPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.fixFitnessPermissions, + true, + ).then((error) => { + if (error) { + fitnessPermissionsCheck.desc = error; } - 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); - } + }); + }; + let checkPerms = () => { + logDebug('fix and refresh fitness permissions'); + return checkOrFix( + fitnessPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.isValidFitnessPermissions, + false, + ); + }; - 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); + 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); + } - 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 setupAndroidBluetoothChecks() { + if (window['device'].version.split('.')[0] >= 10) { + let fixPerms = () => { + logDebug('fix and refresh bluetooth permissions'); + return checkOrFix( + bluetoothPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.fixBluetoothPermissions, + true, + ).then((error) => { + if (error) { + bluetoothPermissionsCheck.desc = error; + } + }); + }; + let checkPerms = () => { + logDebug('fix and refresh bluetooth permissions'); + return checkOrFix( + bluetoothPermissionsCheck, + window['cordova'].plugins.BEMDataCollection.isValidBluetoothPermissions, + 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 bluetoothPermissionsCheck = { + name: 'Bluetooth scan permission', + desc: 'Scan for BLE beacons to automatically match trips to vehicles', + fix: fixPerms, + refresh: checkPerms, + }; + let tempChecks = checkList; + tempChecks.push(bluetoothPermissionsCheck); + 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 - } - let tempChecks = checkList; - tempChecks.push(fitnessPermissionsCheck); - setCheckList(tempChecks); - } + function setupAndroidNotificationChecks() { + let fixPerms = () => { + logDebug('fix and refresh notification permissions'); + return checkOrFix( + appAndChannelNotificationsCheck, + window['cordova'].plugins.BEMDataCollection.fixShowNotifications, + true, + ); + }; + let checkPerms = () => { + logDebug('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 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 = () => { + logDebug('fix and refresh backgroundRestriction permissions'); + return checkOrFix( + unusedAppsUnrestrictedCheck, + window['cordova'].plugins.BEMDataCollection.fixUnusedAppRestrictions, + true, + ); + }; + let checkPerms = () => { + logDebug('fix and refresh backgroundRestriction permissions'); + return checkOrFix( + unusedAppsUnrestrictedCheck, + window['cordova'].plugins.BEMDataCollection.isUnusedAppUnrestricted, + false, + ); + }; + let fixBatteryOpt = () => { + logDebug('fix and refresh battery optimization permissions'); + return checkOrFix( + ignoreBatteryOptCheck, + window['cordova'].plugins.BEMDataCollection.fixIgnoreBatteryOptimizations, + true, + ); + }; + let checkBatteryOpt = () => { + logDebug('fix and refresh battery optimization permissions'); + return checkOrFix( + ignoreBatteryOptCheck, + window['cordova'].plugins.BEMDataCollection.isIgnoreBatteryOptimizations, + false, + ); + }; + const androidVersion = window['device'].version.split('.')[0]; + const androidUnusedDescTag = + androidVersion == 12 + ? 'intro.appstatus.unusedapprestrict.description.android-disable-12' + : androidVersion < 12 + ? 'intro.appstatus.unusedapprestrict.description.android-disable-lt-12' + : 'intro.appstatus.unusedapprestrict.description.android-disable-gte-13'; + 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; + if (appConfig.tracking?.bluetooth_only) { + tempChecks.push(ignoreBatteryOptCheck); + } else { + 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 + logDebug('Explanation = ' + explanationList); + } - console.log("Explanation = "+explanationList); + function createChecklist() { + if (window['device'].platform.toLowerCase() == 'android') { + setupAndroidLocChecks(); + setupAndroidFitnessChecks(); + if (appConfig.tracking?.bluetooth_only) { + setupAndroidBluetoothChecks(); + } + setupAndroidNotificationChecks(); + setupAndroidBackgroundRestrictionChecks(); + } else if (window['device'].platform.toLowerCase() == 'ios') { + setupIOSLocChecks(); + setupIOSFitnessChecks(); + setupAndroidNotificationChecks(); + } else { + setError('Alert! unknownplatform, no tracking'); + setErrorVis(true); + logWarn('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(() => { + logDebug('PERMISSION CHECK: app has resumed, should refresh'); + refreshAllChecks(checkList); + }); + + //load when ready + useEffect(() => { + if (appConfig && window['device']?.platform) { + setupPermissionText(); + setHaveSetText(true); + logDebug('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; diff --git a/www/json/demo-survey-short-v1.json b/www/json/demo-survey-short-v1.json index 20b02817f..6ec5e271f 100644 --- a/www/json/demo-survey-short-v1.json +++ b/www/json/demo-survey-short-v1.json @@ -1 +1 @@ -{"languageMap":{"Spanish (es)":"es","English (en)":"en"},"form":"\n

    OpenPATH Short Demographics Survey

    \n \n \n

    InformaciĆ³n de nivel personalPersonal Level Information

    ĀæCuĆ”l describe de la mejor manera su gĆ©nero?What best describes your gender?*\n
    This field is required
    ĀæCuĆ”l es su raza/etnicidad?What is your race/ethnicity?*Por favor seleccione todas las respuestas vĆ”lidas.Please select all that apply.\n
    This field is required
    ĀæTiene una licencia de conducir vĆ”lida?Do you have a valid drivers license?*\n
    This field is required
    ĀæCuĆ”l es el grado mĆ”s alto o el tĆ­tulo que ha obtenido?What is the highest level of education you have completed?*\n
    This field is required
    \n
    \n

    InformaciĆ³n a nivel del hogarHousehold Level Information

    ĀæCuĆ”l es tu tipo de casa?What is your home type?*\n
    This field is required
    Por favor, identifique quĆ© categorĆ­a representa el ingreso total de su hogar, antes de impuestos, en el Ćŗltimo aƱo.Please identify which category represents your total household income, before taxes, for last year.*Preguntamos esto porque el ingreso estĆ” relacionado con cĆ³mo, cuĆ”ndo y por quĆ© la gente va de un lugar a otro. Esta informaciĆ³n se utilizarĆ” Ćŗnicamente con fines estadĆ­sticos.We are asking this because income is related to how, when and why people go from place to place. This information will be used for statistical purposes only.\n
    This field is required
    IncluyĆ©ndose usted, ĀæcuĆ”ntas personas viven en su casa?Including yourself, how many people live in your home?*\n
    This field is required
    ĀæCuĆ”ntos vehĆ­culos de motor son propiedad, estĆ”n alquilados o estĆ”n disponibles para uso regular por las personas que vive actualmente en su hogar?How many motor vehicles are owned, leased, or available for regular use by the people who currently live in your household?*Incluya motocicletas, ciclomotores y vehĆ­culos recreativos.Include motorcycles, mopeds and RVs.\n
    This field is required
    \n
    \n \n
    ","model":"\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n ","transformerVersion":"2.1.5"} \ No newline at end of file +{"form":"
    \n

    OpenPATH Short Demographics Survey

    \n\n \n \n

    \nInformaciĆ³n de nivel personalPersonal Level Information\n

    \n
    \n
    \n\nĀæCuĆ”l describe de la mejor manera su gĆ©nero?What best describes your gender?*\n \n
    \n\n
    \n
    \nThis field is required\n
    \n
    \n
    \n\nĀæCuĆ”l es su raza/etnicidad?What is your race/ethnicity?*Por favor seleccione todas las respuestas vĆ”lidas.Please select all that apply.\n \n
    \n\n
    \n
    \nThis field is required\n
    \n
    \n
    \n\nĀæTiene una licencia de conducir vĆ”lida?Do you have a valid drivers license?*\n \n
    \n\n
    \n
    \nThis field is required\n
    \n
    \n
    \n\nĀæCuĆ”l es el grado mĆ”s alto o el tĆ­tulo que ha obtenido?What is the highest level of education you have completed?*\n \n
    \n\n
    \n
    \nThis field is required\n
    \n
    \n

    \nInformaciĆ³n a nivel del hogarHousehold Level Information\n

    \n
    \n
    \n\nĀæCuĆ”l es tu tipo de casa?What is your home type?*\n \n
    \n\n
    \n
    \nThis field is required\n
    \n
    \n
    \n\nPor favor, identifique quĆ© categorĆ­a representa el ingreso total de su hogar, antes de impuestos, en el Ćŗltimo aƱo.Please identify which category represents your total household income, before taxes, for last year.*Preguntamos esto porque el ingreso estĆ” relacionado con cĆ³mo, cuĆ”ndo y por quĆ© la gente va de un lugar a otro. Esta informaciĆ³n se utilizarĆ” Ćŗnicamente con fines estadĆ­sticos.We are asking this because income is related to how, when and why people go from place to place. This information will be used for statistical purposes only.\n \n
    \n\n
    \n
    \nThis field is required\n
    \n
    \n
    \n\nIncluyĆ©ndose usted, ĀæcuĆ”ntas personas viven en su casa?Including yourself, how many people live in your home?*\n \n
    \n\n
    \n
    \nThis field is required\n
    \n
    \n
    \n\nĀæCuĆ”ntos vehĆ­culos de motor son propiedad, estĆ”n alquilados o estĆ”n disponibles para uso regular por las personas que vive actualmente en su hogar?How many motor vehicles are owned, leased, or available for regular use by the people who currently live in your household?*Incluya motocicletas, ciclomotores y vehĆ­culos recreativos.Include motorcycles, mopeds and RVs.\n \n
    \n\n
    \n
    \nThis field is required\n
    \n
    \n \n
    \n\n
    \n
    ","model":"\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n","languageMap":{"Spanish (es)":"es","English (en)":"en"},"transformerVersion":"4.0.0"} diff --git a/www/json/demo-survey-short-v1.xml b/www/json/demo-survey-short-v1.xml index bc723f5ee..1f4ce1b36 100644 --- a/www/json/demo-survey-short-v1.xml +++ b/www/json/demo-survey-short-v1.xml @@ -430,7 +430,7 @@ - + diff --git a/www/json/emailConfig.json.sample b/www/json/emailConfig.json.sample deleted file mode 100644 index b1e28e63b..000000000 --- a/www/json/emailConfig.json.sample +++ /dev/null @@ -1,3 +0,0 @@ -{ - "address": "shankari@eecs.berkeley.edu" -} \ No newline at end of file diff --git a/www/json/label-options.json.sample b/www/json/label-options.json.sample new file mode 100644 index 000000000..7947e2149 --- /dev/null +++ b/www/json/label-options.json.sample @@ -0,0 +1,55 @@ +{ + "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":"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":"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"} + ] +} diff --git a/www/json/startupConfig.json b/www/json/startupConfig.json index bf7665f10..a532c39b1 100644 --- a/www/json/startupConfig.json +++ b/www/json/startupConfig.json @@ -1,6 +1,5 @@ { "emSensorDataCollectionProtocol": { - "protocol_id": "2014-04-6267", "approval_date": "2016-07-14" } } diff --git a/www/json/startupConfig.json.sample b/www/json/startupConfig.json.sample index eb386a386..f4d2f434c 100644 --- a/www/json/startupConfig.json.sample +++ b/www/json/startupConfig.json.sample @@ -1,6 +1,5 @@ { "emSensorDataCollectionProtocol": { - "protocol_id": "YYYY-MM-PROTOCOL-NUMBER", "approval_date": "YYYY-MM-DD" } } 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"}] -} diff --git a/www/manual_lib/angular-ui-router/angular-ui-router.js b/www/manual_lib/angular-ui-router/angular-ui-router.js deleted file mode 100644 index ddeb2f950..000000000 --- a/www/manual_lib/angular-ui-router/angular-ui-router.js +++ /dev/null @@ -1,4232 +0,0 @@ -/** - * State-based routing for AngularJS - * @version v0.2.13 - * @link http://angular-ui.github.com/ - * @license MIT License, http://www.opensource.org/licenses/MIT - */ - -/* commonjs package manager support (eg componentjs) */ -if (typeof module !== "undefined" && typeof exports !== "undefined" && module.exports === exports){ - module.exports = 'ui.router'; - } - - (function (window, angular, undefined) { - /*jshint globalstrict:true*/ - /*global angular:false*/ - 'use strict'; - - var isDefined = angular.isDefined, - isFunction = angular.isFunction, - isString = angular.isString, - isObject = angular.isObject, - isArray = angular.isArray, - forEach = angular.forEach, - extend = angular.extend, - copy = angular.copy; - - function inherit(parent, extra) { - return extend(new (extend(function() {}, { prototype: parent }))(), extra); - } - - function merge(dst) { - forEach(arguments, function(obj) { - if (obj !== dst) { - forEach(obj, function(value, key) { - if (!dst.hasOwnProperty(key)) dst[key] = value; - }); - } - }); - return dst; - } - - /** - * Finds the common ancestor path between two states. - * - * @param {Object} first The first state. - * @param {Object} second The second state. - * @return {Array} Returns an array of state names in descending order, not including the root. - */ - function ancestors(first, second) { - var path = []; - - for (var n in first.path) { - if (first.path[n] !== second.path[n]) break; - path.push(first.path[n]); - } - return path; - } - - /** - * IE8-safe wrapper for `Object.keys()`. - * - * @param {Object} object A JavaScript object. - * @return {Array} Returns the keys of the object as an array. - */ - function objectKeys(object) { - if (Object.keys) { - return Object.keys(object); - } - var result = []; - - angular.forEach(object, function(val, key) { - result.push(key); - }); - return result; - } - - /** - * IE8-safe wrapper for `Array.prototype.indexOf()`. - * - * @param {Array} array A JavaScript array. - * @param {*} value A value to search the array for. - * @return {Number} Returns the array index value of `value`, or `-1` if not present. - */ - function indexOf(array, value) { - if (Array.prototype.indexOf) { - return array.indexOf(value, Number(arguments[2]) || 0); - } - var len = array.length >>> 0, from = Number(arguments[2]) || 0; - from = (from < 0) ? Math.ceil(from) : Math.floor(from); - - if (from < 0) from += len; - - for (; from < len; from++) { - if (from in array && array[from] === value) return from; - } - return -1; - } - - /** - * Merges a set of parameters with all parameters inherited between the common parents of the - * current state and a given destination state. - * - * @param {Object} currentParams The value of the current state parameters ($stateParams). - * @param {Object} newParams The set of parameters which will be composited with inherited params. - * @param {Object} $current Internal definition of object representing the current state. - * @param {Object} $to Internal definition of object representing state to transition to. - */ - function inheritParams(currentParams, newParams, $current, $to) { - var parents = ancestors($current, $to), parentParams, inherited = {}, inheritList = []; - - for (var i in parents) { - if (!parents[i].params) continue; - parentParams = objectKeys(parents[i].params); - if (!parentParams.length) continue; - - for (var j in parentParams) { - if (indexOf(inheritList, parentParams[j]) >= 0) continue; - inheritList.push(parentParams[j]); - inherited[parentParams[j]] = currentParams[parentParams[j]]; - } - } - return extend({}, inherited, newParams); - } - - /** - * Performs a non-strict comparison of the subset of two objects, defined by a list of keys. - * - * @param {Object} a The first object. - * @param {Object} b The second object. - * @param {Array} keys The list of keys within each object to compare. If the list is empty or not specified, - * it defaults to the list of keys in `a`. - * @return {Boolean} Returns `true` if the keys match, otherwise `false`. - */ - function equalForKeys(a, b, keys) { - if (!keys) { - keys = []; - for (var n in a) keys.push(n); // Used instead of Object.keys() for IE8 compatibility - } - - for (var i=0; i - * - * - * - * - * - * - * - * - * - * - * - * - */ - angular.module('ui.router', ['ui.router.state']); - - angular.module('ui.router.compat', ['ui.router']); - - /** - * @ngdoc object - * @name ui.router.util.$resolve - * - * @requires $q - * @requires $injector - * - * @description - * Manages resolution of (acyclic) graphs of promises. - */ - $Resolve.$inject = ['$q', '$injector']; - function $Resolve( $q, $injector) { - - var VISIT_IN_PROGRESS = 1, - VISIT_DONE = 2, - NOTHING = {}, - NO_DEPENDENCIES = [], - NO_LOCALS = NOTHING, - NO_PARENT = extend($q.when(NOTHING), { $$promises: NOTHING, $$values: NOTHING }); - - - /** - * @ngdoc function - * @name ui.router.util.$resolve#study - * @methodOf ui.router.util.$resolve - * - * @description - * Studies a set of invocables that are likely to be used multiple times. - *
    -     * $resolve.study(invocables)(locals, parent, self)
    -     * 
    - * is equivalent to - *
    -     * $resolve.resolve(invocables, locals, parent, self)
    -     * 
    - * but the former is more efficient (in fact `resolve` just calls `study` - * internally). - * - * @param {object} invocables Invocable objects - * @return {function} a function to pass in locals, parent and self - */ - this.study = function (invocables) { - if (!isObject(invocables)) throw new Error("'invocables' must be an object"); - var invocableKeys = objectKeys(invocables || {}); - - // Perform a topological sort of invocables to build an ordered plan - var plan = [], cycle = [], visited = {}; - function visit(value, key) { - if (visited[key] === VISIT_DONE) return; - - cycle.push(key); - if (visited[key] === VISIT_IN_PROGRESS) { - cycle.splice(0, indexOf(cycle, key)); - throw new Error("Cyclic dependency: " + cycle.join(" -> ")); - } - visited[key] = VISIT_IN_PROGRESS; - - if (isString(value)) { - plan.push(key, [ function() { return $injector.get(value); }], NO_DEPENDENCIES); - } else { - var params = $injector.annotate(value); - forEach(params, function (param) { - if (param !== key && invocables.hasOwnProperty(param)) visit(invocables[param], param); - }); - plan.push(key, value, params); - } - - cycle.pop(); - visited[key] = VISIT_DONE; - } - forEach(invocables, visit); - invocables = cycle = visited = null; // plan is all that's required - - function isResolve(value) { - return isObject(value) && value.then && value.$$promises; - } - - return function (locals, parent, self) { - if (isResolve(locals) && self === undefined) { - self = parent; parent = locals; locals = null; - } - if (!locals) locals = NO_LOCALS; - else if (!isObject(locals)) { - throw new Error("'locals' must be an object"); - } - if (!parent) parent = NO_PARENT; - else if (!isResolve(parent)) { - throw new Error("'parent' must be a promise returned by $resolve.resolve()"); - } - - // To complete the overall resolution, we have to wait for the parent - // promise and for the promise for each invokable in our plan. - var resolution = $q.defer(), - result = resolution.promise, - promises = result.$$promises = {}, - values = extend({}, locals), - wait = 1 + plan.length/3, - merged = false; - - function done() { - // Merge parent values we haven't got yet and publish our own $$values - if (!--wait) { - if (!merged) merge(values, parent.$$values); - result.$$values = values; - result.$$promises = result.$$promises || true; // keep for isResolve() - delete result.$$inheritedValues; - resolution.resolve(values); - } - } - - function fail(reason) { - result.$$failure = reason; - resolution.reject(reason); - } - - // Short-circuit if parent has already failed - if (isDefined(parent.$$failure)) { - fail(parent.$$failure); - return result; - } - - if (parent.$$inheritedValues) { - merge(values, omit(parent.$$inheritedValues, invocableKeys)); - } - - // Merge parent values if the parent has already resolved, or merge - // parent promises and wait if the parent resolve is still in progress. - extend(promises, parent.$$promises); - if (parent.$$values) { - merged = merge(values, omit(parent.$$values, invocableKeys)); - result.$$inheritedValues = omit(parent.$$values, invocableKeys); - done(); - } else { - if (parent.$$inheritedValues) { - result.$$inheritedValues = omit(parent.$$inheritedValues, invocableKeys); - } - parent.then(done, fail); - } - - // Process each invocable in the plan, but ignore any where a local of the same name exists. - for (var i=0, ii=plan.length; i} The template html as a string, or a promise - * for that string. - */ - this.fromUrl = function (url, params) { - if (isFunction(url)) url = url(params); - if (url == null) return null; - else return $http - .get(url, { cache: $templateCache, headers: { Accept: 'text/html' }}) - .then(function(response) { return response.data; }); - }; - - /** - * @ngdoc function - * @name ui.router.util.$templateFactory#fromProvider - * @methodOf ui.router.util.$templateFactory - * - * @description - * Creates a template by invoking an injectable provider function. - * - * @param {Function} provider Function to invoke via `$injector.invoke` - * @param {Object} params Parameters for the template. - * @param {Object} locals Locals to pass to `invoke`. Defaults to - * `{ params: params }`. - * @return {string|Promise.} The template html as a string, or a promise - * for that string. - */ - this.fromProvider = function (provider, params, locals) { - return $injector.invoke(provider, null, locals || { params: params }); - }; - } - - angular.module('ui.router.util').service('$templateFactory', $TemplateFactory); - - var $$UMFP; // reference to $UrlMatcherFactoryProvider - - /** - * @ngdoc object - * @name ui.router.util.type:UrlMatcher - * - * @description - * Matches URLs against patterns and extracts named parameters from the path or the search - * part of the URL. A URL pattern consists of a path pattern, optionally followed by '?' and a list - * of search parameters. Multiple search parameter names are separated by '&'. Search parameters - * do not influence whether or not a URL is matched, but their values are passed through into - * the matched parameters returned by {@link ui.router.util.type:UrlMatcher#methods_exec exec}. - * - * Path parameter placeholders can be specified using simple colon/catch-all syntax or curly brace - * syntax, which optionally allows a regular expression for the parameter to be specified: - * - * * `':'` name - colon placeholder - * * `'*'` name - catch-all placeholder - * * `'{' name '}'` - curly placeholder - * * `'{' name ':' regexp|type '}'` - curly placeholder with regexp or type name. Should the - * regexp itself contain curly braces, they must be in matched pairs or escaped with a backslash. - * - * Parameter names may contain only word characters (latin letters, digits, and underscore) and - * must be unique within the pattern (across both path and search parameters). For colon - * placeholders or curly placeholders without an explicit regexp, a path parameter matches any - * number of characters other than '/'. For catch-all placeholders the path parameter matches - * any number of characters. - * - * Examples: - * - * * `'/hello/'` - Matches only if the path is exactly '/hello/'. There is no special treatment for - * trailing slashes, and patterns have to match the entire path, not just a prefix. - * * `'/user/:id'` - Matches '/user/bob' or '/user/1234!!!' or even '/user/' but not '/user' or - * '/user/bob/details'. The second path segment will be captured as the parameter 'id'. - * * `'/user/{id}'` - Same as the previous example, but using curly brace syntax. - * * `'/user/{id:[^/]*}'` - Same as the previous example. - * * `'/user/{id:[0-9a-fA-F]{1,8}}'` - Similar to the previous example, but only matches if the id - * parameter consists of 1 to 8 hex digits. - * * `'/files/{path:.*}'` - Matches any URL starting with '/files/' and captures the rest of the - * path into the parameter 'path'. - * * `'/files/*path'` - ditto. - * * `'/calendar/{start:date}'` - Matches "/calendar/2014-11-12" (because the pattern defined - * in the built-in `date` Type matches `2014-11-12`) and provides a Date object in $stateParams.start - * - * @param {string} pattern The pattern to compile into a matcher. - * @param {Object} config A configuration object hash: - * @param {Object=} parentMatcher Used to concatenate the pattern/config onto - * an existing UrlMatcher - * - * * `caseInsensitive` - `true` if URL matching should be case insensitive, otherwise `false`, the default value (for backward compatibility) is `false`. - * * `strict` - `false` if matching against a URL with a trailing slash should be treated as equivalent to a URL without a trailing slash, the default value is `true`. - * - * @property {string} prefix A static prefix of this pattern. The matcher guarantees that any - * URL matching this matcher (i.e. any string for which {@link ui.router.util.type:UrlMatcher#methods_exec exec()} returns - * non-null) will start with this prefix. - * - * @property {string} source The pattern that was passed into the constructor - * - * @property {string} sourcePath The path portion of the source property - * - * @property {string} sourceSearch The search portion of the source property - * - * @property {string} regex The constructed regex that will be used to match against the url when - * it is time to determine which url will match. - * - * @returns {Object} New `UrlMatcher` object - */ - function UrlMatcher(pattern, config, parentMatcher) { - config = extend({ params: {} }, isObject(config) ? config : {}); - - // Find all placeholders and create a compiled pattern, using either classic or curly syntax: - // '*' name - // ':' name - // '{' name '}' - // '{' name ':' regexp '}' - // The regular expression is somewhat complicated due to the need to allow curly braces - // inside the regular expression. The placeholder regexp breaks down as follows: - // ([:*])([\w\[\]]+) - classic placeholder ($1 / $2) (search version has - for snake-case) - // \{([\w\[\]]+)(?:\:( ... ))?\} - curly brace placeholder ($3) with optional regexp/type ... ($4) (search version has - for snake-case - // (?: ... | ... | ... )+ - the regexp consists of any number of atoms, an atom being either - // [^{}\\]+ - anything other than curly braces or backslash - // \\. - a backslash escape - // \{(?:[^{}\\]+|\\.)*\} - a matched set of curly braces containing other atoms - var placeholder = /([:*])([\w\[\]]+)|\{([\w\[\]]+)(?:\:((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g, - searchPlaceholder = /([:]?)([\w\[\]-]+)|\{([\w\[\]-]+)(?:\:((?:[^{}\\]+|\\.|\{(?:[^{}\\]+|\\.)*\})+))?\}/g, - compiled = '^', last = 0, m, - segments = this.segments = [], - parentParams = parentMatcher ? parentMatcher.params : {}, - params = this.params = parentMatcher ? parentMatcher.params.$$new() : new $$UMFP.ParamSet(), - paramNames = []; - - function addParameter(id, type, config, location) { - paramNames.push(id); - if (parentParams[id]) return parentParams[id]; - if (!/^\w+(-+\w+)*(?:\[\])?$/.test(id)) throw new Error("Invalid parameter name '" + id + "' in pattern '" + pattern + "'"); - if (params[id]) throw new Error("Duplicate parameter name '" + id + "' in pattern '" + pattern + "'"); - params[id] = new $$UMFP.Param(id, type, config, location); - return params[id]; - } - - function quoteRegExp(string, pattern, squash) { - var surroundPattern = ['',''], result = string.replace(/[\\\[\]\^$*+?.()|{}]/g, "\\$&"); - if (!pattern) return result; - switch(squash) { - case false: surroundPattern = ['(', ')']; break; - case true: surroundPattern = ['?(', ')?']; break; - default: surroundPattern = ['(' + squash + "|", ')?']; break; - } - return result + surroundPattern[0] + pattern + surroundPattern[1]; - } - - this.source = pattern; - - // Split into static segments separated by path parameter placeholders. - // The number of segments is always 1 more than the number of parameters. - function matchDetails(m, isSearch) { - var id, regexp, segment, type, cfg, arrayMode; - id = m[2] || m[3]; // IE[78] returns '' for unmatched groups instead of null - cfg = config.params[id]; - segment = pattern.substring(last, m.index); - regexp = isSearch ? m[4] : m[4] || (m[1] == '*' ? '.*' : null); - type = $$UMFP.type(regexp || "string") || inherit($$UMFP.type("string"), { pattern: new RegExp(regexp) }); - return { - id: id, regexp: regexp, segment: segment, type: type, cfg: cfg - }; - } - - var p, param, segment; - while ((m = placeholder.exec(pattern))) { - p = matchDetails(m, false); - if (p.segment.indexOf('?') >= 0) break; // we're into the search part - - param = addParameter(p.id, p.type, p.cfg, "path"); - compiled += quoteRegExp(p.segment, param.type.pattern.source, param.squash); - segments.push(p.segment); - last = placeholder.lastIndex; - } - segment = pattern.substring(last); - - // Find any search parameter names and remove them from the last segment - var i = segment.indexOf('?'); - - if (i >= 0) { - var search = this.sourceSearch = segment.substring(i); - segment = segment.substring(0, i); - this.sourcePath = pattern.substring(0, last + i); - - if (search.length > 0) { - last = 0; - while ((m = searchPlaceholder.exec(search))) { - p = matchDetails(m, true); - param = addParameter(p.id, p.type, p.cfg, "search"); - last = placeholder.lastIndex; - // check if ?& - } - } - } else { - this.sourcePath = pattern; - this.sourceSearch = ''; - } - - compiled += quoteRegExp(segment) + (config.strict === false ? '\/?' : '') + '$'; - segments.push(segment); - - this.regexp = new RegExp(compiled, config.caseInsensitive ? 'i' : undefined); - this.prefix = segments[0]; - this.$$paramNames = paramNames; - } - - /** - * @ngdoc function - * @name ui.router.util.type:UrlMatcher#concat - * @methodOf ui.router.util.type:UrlMatcher - * - * @description - * Returns a new matcher for a pattern constructed by appending the path part and adding the - * search parameters of the specified pattern to this pattern. The current pattern is not - * modified. This can be understood as creating a pattern for URLs that are relative to (or - * suffixes of) the current pattern. - * - * @example - * The following two matchers are equivalent: - *
    -   * new UrlMatcher('/user/{id}?q').concat('/details?date');
    -   * new UrlMatcher('/user/{id}/details?q&date');
    -   * 
    - * - * @param {string} pattern The pattern to append. - * @param {Object} config An object hash of the configuration for the matcher. - * @returns {UrlMatcher} A matcher for the concatenated pattern. - */ - UrlMatcher.prototype.concat = function (pattern, config) { - // Because order of search parameters is irrelevant, we can add our own search - // parameters to the end of the new pattern. Parse the new pattern by itself - // and then join the bits together, but it's much easier to do this on a string level. - var defaultConfig = { - caseInsensitive: $$UMFP.caseInsensitive(), - strict: $$UMFP.strictMode(), - squash: $$UMFP.defaultSquashPolicy() - }; - return new UrlMatcher(this.sourcePath + pattern + this.sourceSearch, extend(defaultConfig, config), this); - }; - - UrlMatcher.prototype.toString = function () { - return this.source; - }; - - /** - * @ngdoc function - * @name ui.router.util.type:UrlMatcher#exec - * @methodOf ui.router.util.type:UrlMatcher - * - * @description - * Tests the specified path against this matcher, and returns an object containing the captured - * parameter values, or null if the path does not match. The returned object contains the values - * of any search parameters that are mentioned in the pattern, but their value may be null if - * they are not present in `searchParams`. This means that search parameters are always treated - * as optional. - * - * @example - *
    -   * new UrlMatcher('/user/{id}?q&r').exec('/user/bob', {
    -   *   x: '1', q: 'hello'
    -   * });
    -   * // returns { id: 'bob', q: 'hello', r: null }
    -   * 
    - * - * @param {string} path The URL path to match, e.g. `$location.path()`. - * @param {Object} searchParams URL search parameters, e.g. `$location.search()`. - * @returns {Object} The captured parameter values. - */ - UrlMatcher.prototype.exec = function (path, searchParams) { - var m = this.regexp.exec(path); - if (!m) return null; - searchParams = searchParams || {}; - - var paramNames = this.parameters(), nTotal = paramNames.length, - nPath = this.segments.length - 1, - values = {}, i, j, cfg, paramName; - - if (nPath !== m.length - 1) throw new Error("Unbalanced capture group in route '" + this.source + "'"); - - function decodePathArray(string) { - function reverseString(str) { return str.split("").reverse().join(""); } - function unquoteDashes(str) { return str.replace(/\\-/, "-"); } - - var split = reverseString(string).split(/-(?!\\)/); - var allReversed = map(split, reverseString); - return map(allReversed, unquoteDashes).reverse(); - } - - for (i = 0; i < nPath; i++) { - paramName = paramNames[i]; - var param = this.params[paramName]; - var paramVal = m[i+1]; - // if the param value matches a pre-replace pair, replace the value before decoding. - for (j = 0; j < param.replace; j++) { - if (param.replace[j].from === paramVal) paramVal = param.replace[j].to; - } - if (paramVal && param.array === true) paramVal = decodePathArray(paramVal); - values[paramName] = param.value(paramVal); - } - for (/**/; i < nTotal; i++) { - paramName = paramNames[i]; - values[paramName] = this.params[paramName].value(searchParams[paramName]); - } - - return values; - }; - - /** - * @ngdoc function - * @name ui.router.util.type:UrlMatcher#parameters - * @methodOf ui.router.util.type:UrlMatcher - * - * @description - * Returns the names of all path and search parameters of this pattern in an unspecified order. - * - * @returns {Array.} An array of parameter names. Must be treated as read-only. If the - * pattern has no parameters, an empty array is returned. - */ - UrlMatcher.prototype.parameters = function (param) { - if (!isDefined(param)) return this.$$paramNames; - return this.params[param] || null; - }; - - /** - * @ngdoc function - * @name ui.router.util.type:UrlMatcher#validate - * @methodOf ui.router.util.type:UrlMatcher - * - * @description - * Checks an object hash of parameters to validate their correctness according to the parameter - * types of this `UrlMatcher`. - * - * @param {Object} params The object hash of parameters to validate. - * @returns {boolean} Returns `true` if `params` validates, otherwise `false`. - */ - UrlMatcher.prototype.validates = function (params) { - return this.params.$$validates(params); - }; - - /** - * @ngdoc function - * @name ui.router.util.type:UrlMatcher#format - * @methodOf ui.router.util.type:UrlMatcher - * - * @description - * Creates a URL that matches this pattern by substituting the specified values - * for the path and search parameters. Null values for path parameters are - * treated as empty strings. - * - * @example - *
    -   * new UrlMatcher('/user/{id}?q').format({ id:'bob', q:'yes' });
    -   * // returns '/user/bob?q=yes'
    -   * 
    - * - * @param {Object} values the values to substitute for the parameters in this pattern. - * @returns {string} the formatted URL (path and optionally search part). - */ - UrlMatcher.prototype.format = function (values) { - values = values || {}; - var segments = this.segments, params = this.parameters(), paramset = this.params; - if (!this.validates(values)) return null; - - var i, search = false, nPath = segments.length - 1, nTotal = params.length, result = segments[0]; - - function encodeDashes(str) { // Replace dashes with encoded "\-" - return encodeURIComponent(str).replace(/-/g, function(c) { return '%5C%' + c.charCodeAt(0).toString(16).toUpperCase(); }); - } - - for (i = 0; i < nTotal; i++) { - var isPathParam = i < nPath; - var name = params[i], param = paramset[name], value = param.value(values[name]); - var isDefaultValue = param.isOptional && param.type.equals(param.value(), value); - var squash = isDefaultValue ? param.squash : false; - var encoded = param.type.encode(value); - - if (isPathParam) { - var nextSegment = segments[i + 1]; - if (squash === false) { - if (encoded != null) { - if (isArray(encoded)) { - result += map(encoded, encodeDashes).join("-"); - } else { - result += encodeURIComponent(encoded); - } - } - result += nextSegment; - } else if (squash === true) { - var capture = result.match(/\/$/) ? /\/?(.*)/ : /(.*)/; - result += nextSegment.match(capture)[1]; - } else if (isString(squash)) { - result += squash + nextSegment; - } - } else { - if (encoded == null || (isDefaultValue && squash !== false)) continue; - if (!isArray(encoded)) encoded = [ encoded ]; - encoded = map(encoded, encodeURIComponent).join('&' + name + '='); - result += (search ? '&' : '?') + (name + '=' + encoded); - search = true; - } - } - - return result; - }; - - /** - * @ngdoc object - * @name ui.router.util.type:Type - * - * @description - * Implements an interface to define custom parameter types that can be decoded from and encoded to - * string parameters matched in a URL. Used by {@link ui.router.util.type:UrlMatcher `UrlMatcher`} - * objects when matching or formatting URLs, or comparing or validating parameter values. - * - * See {@link ui.router.util.$urlMatcherFactory#methods_type `$urlMatcherFactory#type()`} for more - * information on registering custom types. - * - * @param {Object} config A configuration object which contains the custom type definition. The object's - * properties will override the default methods and/or pattern in `Type`'s public interface. - * @example - *
    -   * {
    -   *   decode: function(val) { return parseInt(val, 10); },
    -   *   encode: function(val) { return val && val.toString(); },
    -   *   equals: function(a, b) { return this.is(a) && a === b; },
    -   *   is: function(val) { return angular.isNumber(val) isFinite(val) && val % 1 === 0; },
    -   *   pattern: /\d+/
    -   * }
    -   * 
    - * - * @property {RegExp} pattern The regular expression pattern used to match values of this type when - * coming from a substring of a URL. - * - * @returns {Object} Returns a new `Type` object. - */ - function Type(config) { - extend(this, config); - } - - /** - * @ngdoc function - * @name ui.router.util.type:Type#is - * @methodOf ui.router.util.type:Type - * - * @description - * Detects whether a value is of a particular type. Accepts a native (decoded) value - * and determines whether it matches the current `Type` object. - * - * @param {*} val The value to check. - * @param {string} key Optional. If the type check is happening in the context of a specific - * {@link ui.router.util.type:UrlMatcher `UrlMatcher`} object, this is the name of the - * parameter in which `val` is stored. Can be used for meta-programming of `Type` objects. - * @returns {Boolean} Returns `true` if the value matches the type, otherwise `false`. - */ - Type.prototype.is = function(val, key) { - return true; - }; - - /** - * @ngdoc function - * @name ui.router.util.type:Type#encode - * @methodOf ui.router.util.type:Type - * - * @description - * Encodes a custom/native type value to a string that can be embedded in a URL. Note that the - * return value does *not* need to be URL-safe (i.e. passed through `encodeURIComponent()`), it - * only needs to be a representation of `val` that has been coerced to a string. - * - * @param {*} val The value to encode. - * @param {string} key The name of the parameter in which `val` is stored. Can be used for - * meta-programming of `Type` objects. - * @returns {string} Returns a string representation of `val` that can be encoded in a URL. - */ - Type.prototype.encode = function(val, key) { - return val; - }; - - /** - * @ngdoc function - * @name ui.router.util.type:Type#decode - * @methodOf ui.router.util.type:Type - * - * @description - * Converts a parameter value (from URL string or transition param) to a custom/native value. - * - * @param {string} val The URL parameter value to decode. - * @param {string} key The name of the parameter in which `val` is stored. Can be used for - * meta-programming of `Type` objects. - * @returns {*} Returns a custom representation of the URL parameter value. - */ - Type.prototype.decode = function(val, key) { - return val; - }; - - /** - * @ngdoc function - * @name ui.router.util.type:Type#equals - * @methodOf ui.router.util.type:Type - * - * @description - * Determines whether two decoded values are equivalent. - * - * @param {*} a A value to compare against. - * @param {*} b A value to compare against. - * @returns {Boolean} Returns `true` if the values are equivalent/equal, otherwise `false`. - */ - Type.prototype.equals = function(a, b) { - return a == b; - }; - - Type.prototype.$subPattern = function() { - var sub = this.pattern.toString(); - return sub.substr(1, sub.length - 2); - }; - - Type.prototype.pattern = /.*/; - - Type.prototype.toString = function() { return "{Type:" + this.name + "}"; }; - - /* - * Wraps an existing custom Type as an array of Type, depending on 'mode'. - * e.g.: - * - urlmatcher pattern "/path?{queryParam[]:int}" - * - url: "/path?queryParam=1&queryParam=2 - * - $stateParams.queryParam will be [1, 2] - * if `mode` is "auto", then - * - url: "/path?queryParam=1 will create $stateParams.queryParam: 1 - * - url: "/path?queryParam=1&queryParam=2 will create $stateParams.queryParam: [1, 2] - */ - Type.prototype.$asArray = function(mode, isSearch) { - if (!mode) return this; - if (mode === "auto" && !isSearch) throw new Error("'auto' array mode is for query parameters only"); - return new ArrayType(this, mode); - - function ArrayType(type, mode) { - function bindTo(type, callbackName) { - return function() { - return type[callbackName].apply(type, arguments); - }; - } - - // Wrap non-array value as array - function arrayWrap(val) { return isArray(val) ? val : (isDefined(val) ? [ val ] : []); } - // Unwrap array value for "auto" mode. Return undefined for empty array. - function arrayUnwrap(val) { - switch(val.length) { - case 0: return undefined; - case 1: return mode === "auto" ? val[0] : val; - default: return val; - } - } - function falsey(val) { return !val; } - - // Wraps type (.is/.encode/.decode) functions to operate on each value of an array - function arrayHandler(callback, allTruthyMode) { - return function handleArray(val) { - val = arrayWrap(val); - var result = map(val, callback); - if (allTruthyMode === true) - return filter(result, falsey).length === 0; - return arrayUnwrap(result); - }; - } - - // Wraps type (.equals) functions to operate on each value of an array - function arrayEqualsHandler(callback) { - return function handleArray(val1, val2) { - var left = arrayWrap(val1), right = arrayWrap(val2); - if (left.length !== right.length) return false; - for (var i = 0; i < left.length; i++) { - if (!callback(left[i], right[i])) return false; - } - return true; - }; - } - - this.encode = arrayHandler(bindTo(type, 'encode')); - this.decode = arrayHandler(bindTo(type, 'decode')); - this.is = arrayHandler(bindTo(type, 'is'), true); - this.equals = arrayEqualsHandler(bindTo(type, 'equals')); - this.pattern = type.pattern; - this.$arrayMode = mode; - } - }; - - - - /** - * @ngdoc object - * @name ui.router.util.$urlMatcherFactory - * - * @description - * Factory for {@link ui.router.util.type:UrlMatcher `UrlMatcher`} instances. The factory - * is also available to providers under the name `$urlMatcherFactoryProvider`. - */ - function $UrlMatcherFactory() { - $$UMFP = this; - - var isCaseInsensitive = false, isStrictMode = true, defaultSquashPolicy = false; - - function valToString(val) { return val != null ? val.toString().replace(/\//g, "%2F") : val; } - function valFromString(val) { return val != null ? val.toString().replace(/%2F/g, "/") : val; } - // TODO: in 1.0, make string .is() return false if value is undefined by default. - // function regexpMatches(val) { /*jshint validthis:true */ return isDefined(val) && this.pattern.test(val); } - function regexpMatches(val) { /*jshint validthis:true */ return this.pattern.test(val); } - - var $types = {}, enqueue = true, typeQueue = [], injector, defaultTypes = { - string: { - encode: valToString, - decode: valFromString, - is: regexpMatches, - pattern: /[^/]*/ - }, - int: { - encode: valToString, - decode: function(val) { return parseInt(val, 10); }, - is: function(val) { return isDefined(val) && this.decode(val.toString()) === val; }, - pattern: /\d+/ - }, - bool: { - encode: function(val) { return val ? 1 : 0; }, - decode: function(val) { return parseInt(val, 10) !== 0; }, - is: function(val) { return val === true || val === false; }, - pattern: /0|1/ - }, - date: { - encode: function (val) { - if (!this.is(val)) - return undefined; - return [ val.getFullYear(), - ('0' + (val.getMonth() + 1)).slice(-2), - ('0' + val.getDate()).slice(-2) - ].join("-"); - }, - decode: function (val) { - if (this.is(val)) return val; - var match = this.capture.exec(val); - return match ? new Date(match[1], match[2] - 1, match[3]) : undefined; - }, - is: function(val) { return val instanceof Date && !isNaN(val.valueOf()); }, - equals: function (a, b) { return this.is(a) && this.is(b) && a.toISOString() === b.toISOString(); }, - pattern: /[0-9]{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[1-2][0-9]|3[0-1])/, - capture: /([0-9]{4})-(0[1-9]|1[0-2])-(0[1-9]|[1-2][0-9]|3[0-1])/ - }, - json: { - encode: angular.toJson, - decode: angular.fromJson, - is: angular.isObject, - equals: angular.equals, - pattern: /[^/]*/ - }, - any: { // does not encode/decode - encode: angular.identity, - decode: angular.identity, - is: angular.identity, - equals: angular.equals, - pattern: /.*/ - } - }; - - function getDefaultConfig() { - return { - strict: isStrictMode, - caseInsensitive: isCaseInsensitive - }; - } - - function isInjectable(value) { - return (isFunction(value) || (isArray(value) && isFunction(value[value.length - 1]))); - } - - /** - * [Internal] Get the default value of a parameter, which may be an injectable function. - */ - $UrlMatcherFactory.$$getDefaultValue = function(config) { - if (!isInjectable(config.value)) return config.value; - if (!injector) throw new Error("Injectable functions cannot be called at configuration time"); - return injector.invoke(config.value); - }; - - /** - * @ngdoc function - * @name ui.router.util.$urlMatcherFactory#caseInsensitive - * @methodOf ui.router.util.$urlMatcherFactory - * - * @description - * Defines whether URL matching should be case sensitive (the default behavior), or not. - * - * @param {boolean} value `false` to match URL in a case sensitive manner; otherwise `true`; - * @returns {boolean} the current value of caseInsensitive - */ - this.caseInsensitive = function(value) { - if (isDefined(value)) - isCaseInsensitive = value; - return isCaseInsensitive; - }; - - /** - * @ngdoc function - * @name ui.router.util.$urlMatcherFactory#strictMode - * @methodOf ui.router.util.$urlMatcherFactory - * - * @description - * Defines whether URLs should match trailing slashes, or not (the default behavior). - * - * @param {boolean=} value `false` to match trailing slashes in URLs, otherwise `true`. - * @returns {boolean} the current value of strictMode - */ - this.strictMode = function(value) { - if (isDefined(value)) - isStrictMode = value; - return isStrictMode; - }; - - /** - * @ngdoc function - * @name ui.router.util.$urlMatcherFactory#defaultSquashPolicy - * @methodOf ui.router.util.$urlMatcherFactory - * - * @description - * Sets the default behavior when generating or matching URLs with default parameter values. - * - * @param {string} value A string that defines the default parameter URL squashing behavior. - * `nosquash`: When generating an href with a default parameter value, do not squash the parameter value from the URL - * `slash`: When generating an href with a default parameter value, squash (remove) the parameter value, and, if the - * parameter is surrounded by slashes, squash (remove) one slash from the URL - * any other string, e.g. "~": When generating an href with a default parameter value, squash (remove) - * the parameter value from the URL and replace it with this string. - */ - this.defaultSquashPolicy = function(value) { - if (!isDefined(value)) return defaultSquashPolicy; - if (value !== true && value !== false && !isString(value)) - throw new Error("Invalid squash policy: " + value + ". Valid policies: false, true, arbitrary-string"); - defaultSquashPolicy = value; - return value; - }; - - /** - * @ngdoc function - * @name ui.router.util.$urlMatcherFactory#compile - * @methodOf ui.router.util.$urlMatcherFactory - * - * @description - * Creates a {@link ui.router.util.type:UrlMatcher `UrlMatcher`} for the specified pattern. - * - * @param {string} pattern The URL pattern. - * @param {Object} config The config object hash. - * @returns {UrlMatcher} The UrlMatcher. - */ - this.compile = function (pattern, config) { - return new UrlMatcher(pattern, extend(getDefaultConfig(), config)); - }; - - /** - * @ngdoc function - * @name ui.router.util.$urlMatcherFactory#isMatcher - * @methodOf ui.router.util.$urlMatcherFactory - * - * @description - * Returns true if the specified object is a `UrlMatcher`, or false otherwise. - * - * @param {Object} object The object to perform the type check against. - * @returns {Boolean} Returns `true` if the object matches the `UrlMatcher` interface, by - * implementing all the same methods. - */ - this.isMatcher = function (o) { - if (!isObject(o)) return false; - var result = true; - - forEach(UrlMatcher.prototype, function(val, name) { - if (isFunction(val)) { - result = result && (isDefined(o[name]) && isFunction(o[name])); - } - }); - return result; - }; - - /** - * @ngdoc function - * @name ui.router.util.$urlMatcherFactory#type - * @methodOf ui.router.util.$urlMatcherFactory - * - * @description - * Registers a custom {@link ui.router.util.type:Type `Type`} object that can be used to - * generate URLs with typed parameters. - * - * @param {string} name The type name. - * @param {Object|Function} definition The type definition. See - * {@link ui.router.util.type:Type `Type`} for information on the values accepted. - * @param {Object|Function} definitionFn (optional) A function that is injected before the app - * runtime starts. The result of this function is merged into the existing `definition`. - * See {@link ui.router.util.type:Type `Type`} for information on the values accepted. - * - * @returns {Object} Returns `$urlMatcherFactoryProvider`. - * - * @example - * This is a simple example of a custom type that encodes and decodes items from an - * array, using the array index as the URL-encoded value: - * - *
    -     * var list = ['John', 'Paul', 'George', 'Ringo'];
    -     *
    -     * $urlMatcherFactoryProvider.type('listItem', {
    -     *   encode: function(item) {
    -     *     // Represent the list item in the URL using its corresponding index
    -     *     return list.indexOf(item);
    -     *   },
    -     *   decode: function(item) {
    -     *     // Look up the list item by index
    -     *     return list[parseInt(item, 10)];
    -     *   },
    -     *   is: function(item) {
    -     *     // Ensure the item is valid by checking to see that it appears
    -     *     // in the list
    -     *     return list.indexOf(item) > -1;
    -     *   }
    -     * });
    -     *
    -     * $stateProvider.state('list', {
    -     *   url: "/list/{item:listItem}",
    -     *   controller: function($scope, $stateParams) {
    -     *     console.log($stateParams.item);
    -     *   }
    -     * });
    -     *
    -     * // ...
    -     *
    -     * // Changes URL to '/list/3', logs "Ringo" to the console
    -     * $state.go('list', { item: "Ringo" });
    -     * 
    - * - * This is a more complex example of a type that relies on dependency injection to - * interact with services, and uses the parameter name from the URL to infer how to - * handle encoding and decoding parameter values: - * - *
    -     * // Defines a custom type that gets a value from a service,
    -     * // where each service gets different types of values from
    -     * // a backend API:
    -     * $urlMatcherFactoryProvider.type('dbObject', {}, function(Users, Posts) {
    -     *
    -     *   // Matches up services to URL parameter names
    -     *   var services = {
    -     *     user: Users,
    -     *     post: Posts
    -     *   };
    -     *
    -     *   return {
    -     *     encode: function(object) {
    -     *       // Represent the object in the URL using its unique ID
    -     *       return object.id;
    -     *     },
    -     *     decode: function(value, key) {
    -     *       // Look up the object by ID, using the parameter
    -     *       // name (key) to call the correct service
    -     *       return services[key].findById(value);
    -     *     },
    -     *     is: function(object, key) {
    -     *       // Check that object is a valid dbObject
    -     *       return angular.isObject(object) && object.id && services[key];
    -     *     }
    -     *     equals: function(a, b) {
    -     *       // Check the equality of decoded objects by comparing
    -     *       // their unique IDs
    -     *       return a.id === b.id;
    -     *     }
    -     *   };
    -     * });
    -     *
    -     * // In a config() block, you can then attach URLs with
    -     * // type-annotated parameters:
    -     * $stateProvider.state('users', {
    -     *   url: "/users",
    -     *   // ...
    -     * }).state('users.item', {
    -     *   url: "/{user:dbObject}",
    -     *   controller: function($scope, $stateParams) {
    -     *     // $stateParams.user will now be an object returned from
    -     *     // the Users service
    -     *   },
    -     *   // ...
    -     * });
    -     * 
    - */ - this.type = function (name, definition, definitionFn) { - if (!isDefined(definition)) return $types[name]; - if ($types.hasOwnProperty(name)) throw new Error("A type named '" + name + "' has already been defined."); - - $types[name] = new Type(extend({ name: name }, definition)); - if (definitionFn) { - typeQueue.push({ name: name, def: definitionFn }); - if (!enqueue) flushTypeQueue(); - } - return this; - }; - - // `flushTypeQueue()` waits until `$urlMatcherFactory` is injected before invoking the queued `definitionFn`s - function flushTypeQueue() { - while(typeQueue.length) { - var type = typeQueue.shift(); - if (type.pattern) throw new Error("You cannot override a type's .pattern at runtime."); - angular.extend($types[type.name], injector.invoke(type.def)); - } - } - - // Register default types. Store them in the prototype of $types. - forEach(defaultTypes, function(type, name) { $types[name] = new Type(extend({name: name}, type)); }); - $types = inherit($types, {}); - - /* No need to document $get, since it returns this */ - this.$get = ['$injector', function ($injector) { - injector = $injector; - enqueue = false; - flushTypeQueue(); - - forEach(defaultTypes, function(type, name) { - if (!$types[name]) $types[name] = new Type(type); - }); - return this; - }]; - - this.Param = function Param(id, type, config, location) { - var self = this; - config = unwrapShorthand(config); - type = getType(config, type, location); - var arrayMode = getArrayMode(); - type = arrayMode ? type.$asArray(arrayMode, location === "search") : type; - if (type.name === "string" && !arrayMode && location === "path" && config.value === undefined) - config.value = ""; // for 0.2.x; in 0.3.0+ do not automatically default to "" - var isOptional = config.value !== undefined; - var squash = getSquashPolicy(config, isOptional); - var replace = getReplace(config, arrayMode, isOptional, squash); - - function unwrapShorthand(config) { - var keys = isObject(config) ? objectKeys(config) : []; - var isShorthand = indexOf(keys, "value") === -1 && indexOf(keys, "type") === -1 && - indexOf(keys, "squash") === -1 && indexOf(keys, "array") === -1; - if (isShorthand) config = { value: config }; - config.$$fn = isInjectable(config.value) ? config.value : function () { return config.value; }; - return config; - } - - function getType(config, urlType, location) { - if (config.type && urlType) throw new Error("Param '"+id+"' has two type configurations."); - if (urlType) return urlType; - if (!config.type) return (location === "config" ? $types.any : $types.string); - return config.type instanceof Type ? config.type : new Type(config.type); - } - - // array config: param name (param[]) overrides default settings. explicit config overrides param name. - function getArrayMode() { - var arrayDefaults = { array: (location === "search" ? "auto" : false) }; - var arrayParamNomenclature = id.match(/\[\]$/) ? { array: true } : {}; - return extend(arrayDefaults, arrayParamNomenclature, config).array; - } - - /** - * returns false, true, or the squash value to indicate the "default parameter url squash policy". - */ - function getSquashPolicy(config, isOptional) { - var squash = config.squash; - if (!isOptional || squash === false) return false; - if (!isDefined(squash) || squash == null) return defaultSquashPolicy; - if (squash === true || isString(squash)) return squash; - throw new Error("Invalid squash policy: '" + squash + "'. Valid policies: false, true, or arbitrary string"); - } - - function getReplace(config, arrayMode, isOptional, squash) { - var replace, configuredKeys, defaultPolicy = [ - { from: "", to: (isOptional || arrayMode ? undefined : "") }, - { from: null, to: (isOptional || arrayMode ? undefined : "") } - ]; - replace = isArray(config.replace) ? config.replace : []; - if (isString(squash)) - replace.push({ from: squash, to: undefined }); - configuredKeys = map(replace, function(item) { return item.from; } ); - return filter(defaultPolicy, function(item) { return indexOf(configuredKeys, item.from) === -1; }).concat(replace); - } - - /** - * [Internal] Get the default value of a parameter, which may be an injectable function. - */ - function $$getDefaultValue() { - if (!injector) throw new Error("Injectable functions cannot be called at configuration time"); - return injector.invoke(config.$$fn); - } - - /** - * [Internal] Gets the decoded representation of a value if the value is defined, otherwise, returns the - * default value, which may be the result of an injectable function. - */ - function $value(value) { - function hasReplaceVal(val) { return function(obj) { return obj.from === val; }; } - function $replace(value) { - var replacement = map(filter(self.replace, hasReplaceVal(value)), function(obj) { return obj.to; }); - return replacement.length ? replacement[0] : value; - } - value = $replace(value); - return isDefined(value) ? self.type.decode(value) : $$getDefaultValue(); - } - - function toString() { return "{Param:" + id + " " + type + " squash: '" + squash + "' optional: " + isOptional + "}"; } - - extend(this, { - id: id, - type: type, - location: location, - array: arrayMode, - squash: squash, - replace: replace, - isOptional: isOptional, - value: $value, - dynamic: undefined, - config: config, - toString: toString - }); - }; - - function ParamSet(params) { - extend(this, params || {}); - } - - ParamSet.prototype = { - $$new: function() { - return inherit(this, extend(new ParamSet(), { $$parent: this})); - }, - $$keys: function () { - var keys = [], chain = [], parent = this, - ignore = objectKeys(ParamSet.prototype); - while (parent) { chain.push(parent); parent = parent.$$parent; } - chain.reverse(); - forEach(chain, function(paramset) { - forEach(objectKeys(paramset), function(key) { - if (indexOf(keys, key) === -1 && indexOf(ignore, key) === -1) keys.push(key); - }); - }); - return keys; - }, - $$values: function(paramValues) { - var values = {}, self = this; - forEach(self.$$keys(), function(key) { - values[key] = self[key].value(paramValues && paramValues[key]); - }); - return values; - }, - $$equals: function(paramValues1, paramValues2) { - var equal = true, self = this; - forEach(self.$$keys(), function(key) { - var left = paramValues1 && paramValues1[key], right = paramValues2 && paramValues2[key]; - if (!self[key].type.equals(left, right)) equal = false; - }); - return equal; - }, - $$validates: function $$validate(paramValues) { - var result = true, isOptional, val, param, self = this; - - forEach(this.$$keys(), function(key) { - param = self[key]; - val = paramValues[key]; - isOptional = !val && param.isOptional; - result = result && (isOptional || !!param.type.is(val)); - }); - return result; - }, - $$parent: undefined - }; - - this.ParamSet = ParamSet; - } - - // Register as a provider so it's available to other providers - angular.module('ui.router.util').provider('$urlMatcherFactory', $UrlMatcherFactory); - angular.module('ui.router.util').run(['$urlMatcherFactory', function($urlMatcherFactory) { }]); - - /** - * @ngdoc object - * @name ui.router.router.$urlRouterProvider - * - * @requires ui.router.util.$urlMatcherFactoryProvider - * @requires $locationProvider - * - * @description - * `$urlRouterProvider` has the responsibility of watching `$location`. - * When `$location` changes it runs through a list of rules one by one until a - * match is found. `$urlRouterProvider` is used behind the scenes anytime you specify - * a url in a state configuration. All urls are compiled into a UrlMatcher object. - * - * There are several methods on `$urlRouterProvider` that make it useful to use directly - * in your module config. - */ - $UrlRouterProvider.$inject = ['$locationProvider', '$urlMatcherFactoryProvider']; - function $UrlRouterProvider( $locationProvider, $urlMatcherFactory) { - var rules = [], otherwise = null, interceptDeferred = false, listener; - - // Returns a string that is a prefix of all strings matching the RegExp - function regExpPrefix(re) { - var prefix = /^\^((?:\\[^a-zA-Z0-9]|[^\\\[\]\^$*+?.()|{}]+)*)/.exec(re.source); - return (prefix != null) ? prefix[1].replace(/\\(.)/g, "$1") : ''; - } - - // Interpolates matched values into a String.replace()-style pattern - function interpolate(pattern, match) { - return pattern.replace(/\$(\$|\d{1,2})/, function (m, what) { - return match[what === '$' ? 0 : Number(what)]; - }); - } - - /** - * @ngdoc function - * @name ui.router.router.$urlRouterProvider#rule - * @methodOf ui.router.router.$urlRouterProvider - * - * @description - * Defines rules that are used by `$urlRouterProvider` to find matches for - * specific URLs. - * - * @example - *
    -     * var app = angular.module('app', ['ui.router.router']);
    -     *
    -     * app.config(function ($urlRouterProvider) {
    -     *   // Here's an example of how you might allow case insensitive urls
    -     *   $urlRouterProvider.rule(function ($injector, $location) {
    -     *     var path = $location.path(),
    -     *         normalized = path.toLowerCase();
    -     *
    -     *     if (path !== normalized) {
    -     *       return normalized;
    -     *     }
    -     *   });
    -     * });
    -     * 
    - * - * @param {object} rule Handler function that takes `$injector` and `$location` - * services as arguments. You can use them to return a valid path as a string. - * - * @return {object} `$urlRouterProvider` - `$urlRouterProvider` instance - */ - this.rule = function (rule) { - if (!isFunction(rule)) throw new Error("'rule' must be a function"); - rules.push(rule); - return this; - }; - - /** - * @ngdoc object - * @name ui.router.router.$urlRouterProvider#otherwise - * @methodOf ui.router.router.$urlRouterProvider - * - * @description - * Defines a path that is used when an invalid route is requested. - * - * @example - *
    -     * var app = angular.module('app', ['ui.router.router']);
    -     *
    -     * app.config(function ($urlRouterProvider) {
    -     *   // if the path doesn't match any of the urls you configured
    -     *   // otherwise will take care of routing the user to the
    -     *   // specified url
    -     *   $urlRouterProvider.otherwise('/index');
    -     *
    -     *   // Example of using function rule as param
    -     *   $urlRouterProvider.otherwise(function ($injector, $location) {
    -     *     return '/a/valid/url';
    -     *   });
    -     * });
    -     * 
    - * - * @param {string|object} rule The url path you want to redirect to or a function - * rule that returns the url path. The function version is passed two params: - * `$injector` and `$location` services, and must return a url string. - * - * @return {object} `$urlRouterProvider` - `$urlRouterProvider` instance - */ - this.otherwise = function (rule) { - if (isString(rule)) { - var redirect = rule; - rule = function () { return redirect; }; - } - else if (!isFunction(rule)) throw new Error("'rule' must be a function"); - otherwise = rule; - return this; - }; - - - function handleIfMatch($injector, handler, match) { - if (!match) return false; - var result = $injector.invoke(handler, handler, { $match: match }); - return isDefined(result) ? result : true; - } - - /** - * @ngdoc function - * @name ui.router.router.$urlRouterProvider#when - * @methodOf ui.router.router.$urlRouterProvider - * - * @description - * Registers a handler for a given url matching. if handle is a string, it is - * treated as a redirect, and is interpolated according to the syntax of match - * (i.e. like `String.replace()` for `RegExp`, or like a `UrlMatcher` pattern otherwise). - * - * If the handler is a function, it is injectable. It gets invoked if `$location` - * matches. You have the option of inject the match object as `$match`. - * - * The handler can return - * - * - **falsy** to indicate that the rule didn't match after all, then `$urlRouter` - * will continue trying to find another one that matches. - * - **string** which is treated as a redirect and passed to `$location.url()` - * - **void** or any **truthy** value tells `$urlRouter` that the url was handled. - * - * @example - *
    -     * var app = angular.module('app', ['ui.router.router']);
    -     *
    -     * app.config(function ($urlRouterProvider) {
    -     *   $urlRouterProvider.when($state.url, function ($match, $stateParams) {
    -     *     if ($state.$current.navigable !== state ||
    -     *         !equalForKeys($match, $stateParams) {
    -     *      $state.transitionTo(state, $match, false);
    -     *     }
    -     *   });
    -     * });
    -     * 
    - * - * @param {string|object} what The incoming path that you want to redirect. - * @param {string|object} handler The path you want to redirect your user to. - */ - this.when = function (what, handler) { - var redirect, handlerIsString = isString(handler); - if (isString(what)) what = $urlMatcherFactory.compile(what); - - if (!handlerIsString && !isFunction(handler) && !isArray(handler)) - throw new Error("invalid 'handler' in when()"); - - var strategies = { - matcher: function (what, handler) { - if (handlerIsString) { - redirect = $urlMatcherFactory.compile(handler); - handler = ['$match', function ($match) { return redirect.format($match); }]; - } - return extend(function ($injector, $location) { - return handleIfMatch($injector, handler, what.exec($location.path(), $location.search())); - }, { - prefix: isString(what.prefix) ? what.prefix : '' - }); - }, - regex: function (what, handler) { - if (what.global || what.sticky) throw new Error("when() RegExp must not be global or sticky"); - - if (handlerIsString) { - redirect = handler; - handler = ['$match', function ($match) { return interpolate(redirect, $match); }]; - } - return extend(function ($injector, $location) { - return handleIfMatch($injector, handler, what.exec($location.path())); - }, { - prefix: regExpPrefix(what) - }); - } - }; - - var check = { matcher: $urlMatcherFactory.isMatcher(what), regex: what instanceof RegExp }; - - for (var n in check) { - if (check[n]) return this.rule(strategies[n](what, handler)); - } - - throw new Error("invalid 'what' in when()"); - }; - - /** - * @ngdoc function - * @name ui.router.router.$urlRouterProvider#deferIntercept - * @methodOf ui.router.router.$urlRouterProvider - * - * @description - * Disables (or enables) deferring location change interception. - * - * If you wish to customize the behavior of syncing the URL (for example, if you wish to - * defer a transition but maintain the current URL), call this method at configuration time. - * Then, at run time, call `$urlRouter.listen()` after you have configured your own - * `$locationChangeSuccess` event handler. - * - * @example - *
    -     * var app = angular.module('app', ['ui.router.router']);
    -     *
    -     * app.config(function ($urlRouterProvider) {
    -     *
    -     *   // Prevent $urlRouter from automatically intercepting URL changes;
    -     *   // this allows you to configure custom behavior in between
    -     *   // location changes and route synchronization:
    -     *   $urlRouterProvider.deferIntercept();
    -     *
    -     * }).run(function ($rootScope, $urlRouter, UserService) {
    -     *
    -     *   $rootScope.$on('$locationChangeSuccess', function(e) {
    -     *     // UserService is an example service for managing user state
    -     *     if (UserService.isLoggedIn()) return;
    -     *
    -     *     // Prevent $urlRouter's default handler from firing
    -     *     e.preventDefault();
    -     *
    -     *     UserService.handleLogin().then(function() {
    -     *       // Once the user has logged in, sync the current URL
    -     *       // to the router:
    -     *       $urlRouter.sync();
    -     *     });
    -     *   });
    -     *
    -     *   // Configures $urlRouter's listener *after* your custom listener
    -     *   $urlRouter.listen();
    -     * });
    -     * 
    - * - * @param {boolean} defer Indicates whether to defer location change interception. Passing - no parameter is equivalent to `true`. - */ - this.deferIntercept = function (defer) { - if (defer === undefined) defer = true; - interceptDeferred = defer; - }; - - /** - * @ngdoc object - * @name ui.router.router.$urlRouter - * - * @requires $location - * @requires $rootScope - * @requires $injector - * @requires $browser - * - * @description - * - */ - this.$get = $get; - $get.$inject = ['$location', '$rootScope', '$injector', '$browser']; - function $get( $location, $rootScope, $injector, $browser) { - - var baseHref = $browser.baseHref(), location = $location.url(), lastPushedUrl; - - function appendBasePath(url, isHtml5, absolute) { - if (baseHref === '/') return url; - if (isHtml5) return baseHref.slice(0, -1) + url; - if (absolute) return baseHref.slice(1) + url; - return url; - } - - // TODO: Optimize groups of rules with non-empty prefix into some sort of decision tree - function update(evt) { - if (evt && evt.defaultPrevented) return; - var ignoreUpdate = lastPushedUrl && $location.url() === lastPushedUrl; - lastPushedUrl = undefined; - if (ignoreUpdate) return true; - - function check(rule) { - var handled = rule($injector, $location); - - if (!handled) return false; - if (isString(handled)) $location.replace().url(handled); - return true; - } - var n = rules.length, i; - - for (i = 0; i < n; i++) { - if (check(rules[i])) return; - } - // always check otherwise last to allow dynamic updates to the set of rules - if (otherwise) check(otherwise); - } - - function listen() { - listener = listener || $rootScope.$on('$locationChangeSuccess', update); - return listener; - } - - if (!interceptDeferred) listen(); - - return { - /** - * @ngdoc function - * @name ui.router.router.$urlRouter#sync - * @methodOf ui.router.router.$urlRouter - * - * @description - * Triggers an update; the same update that happens when the address bar url changes, aka `$locationChangeSuccess`. - * This method is useful when you need to use `preventDefault()` on the `$locationChangeSuccess` event, - * perform some custom logic (route protection, auth, config, redirection, etc) and then finally proceed - * with the transition by calling `$urlRouter.sync()`. - * - * @example - *
    -         * angular.module('app', ['ui.router'])
    -         *   .run(function($rootScope, $urlRouter) {
    -         *     $rootScope.$on('$locationChangeSuccess', function(evt) {
    -         *       // Halt state change from even starting
    -         *       evt.preventDefault();
    -         *       // Perform custom logic
    -         *       var meetsRequirement = ...
    -         *       // Continue with the update and state transition if logic allows
    -         *       if (meetsRequirement) $urlRouter.sync();
    -         *     });
    -         * });
    -         * 
    - */ - sync: function() { - update(); - }, - - listen: function() { - return listen(); - }, - - update: function(read) { - if (read) { - location = $location.url(); - return; - } - if ($location.url() === location) return; - - $location.url(location); - $location.replace(); - }, - - push: function(urlMatcher, params, options) { - $location.url(urlMatcher.format(params || {})); - lastPushedUrl = options && options.$$avoidResync ? $location.url() : undefined; - if (options && options.replace) $location.replace(); - }, - - /** - * @ngdoc function - * @name ui.router.router.$urlRouter#href - * @methodOf ui.router.router.$urlRouter - * - * @description - * A URL generation method that returns the compiled URL for a given - * {@link ui.router.util.type:UrlMatcher `UrlMatcher`}, populated with the provided parameters. - * - * @example - *
    -         * $bob = $urlRouter.href(new UrlMatcher("/about/:person"), {
    -         *   person: "bob"
    -         * });
    -         * // $bob == "/about/bob";
    -         * 
    - * - * @param {UrlMatcher} urlMatcher The `UrlMatcher` object which is used as the template of the URL to generate. - * @param {object=} params An object of parameter values to fill the matcher's required parameters. - * @param {object=} options Options object. The options are: - * - * - **`absolute`** - {boolean=false}, If true will generate an absolute url, e.g. "http://www.example.com/fullurl". - * - * @returns {string} Returns the fully compiled URL, or `null` if `params` fail validation against `urlMatcher` - */ - href: function(urlMatcher, params, options) { - if (!urlMatcher.validates(params)) return null; - - var isHtml5 = $locationProvider.html5Mode(); - if (angular.isObject(isHtml5)) { - isHtml5 = isHtml5.enabled; - } - - var url = urlMatcher.format(params); - options = options || {}; - - if (!isHtml5 && url !== null) { - url = "#" + $locationProvider.hashPrefix() + url; - } - url = appendBasePath(url, isHtml5, options.absolute); - - if (!options.absolute || !url) { - return url; - } - - var slash = (!isHtml5 && url ? '/' : ''), port = $location.port(); - port = (port === 80 || port === 443 ? '' : ':' + port); - - return [$location.protocol(), '://', $location.host(), port, slash, url].join(''); - } - }; - } - } - - angular.module('ui.router.router').provider('$urlRouter', $UrlRouterProvider); - - /** - * @ngdoc object - * @name ui.router.state.$stateProvider - * - * @requires ui.router.router.$urlRouterProvider - * @requires ui.router.util.$urlMatcherFactoryProvider - * - * @description - * The new `$stateProvider` works similar to Angular's v1 router, but it focuses purely - * on state. - * - * A state corresponds to a "place" in the application in terms of the overall UI and - * navigation. A state describes (via the controller / template / view properties) what - * the UI looks like and does at that place. - * - * States often have things in common, and the primary way of factoring out these - * commonalities in this model is via the state hierarchy, i.e. parent/child states aka - * nested states. - * - * The `$stateProvider` provides interfaces to declare these states for your app. - */ - $StateProvider.$inject = ['$urlRouterProvider', '$urlMatcherFactoryProvider']; - function $StateProvider( $urlRouterProvider, $urlMatcherFactory) { - - var root, states = {}, $state, queue = {}, abstractKey = 'abstract'; - - // Builds state properties from definition passed to registerState() - var stateBuilder = { - - // Derive parent state from a hierarchical name only if 'parent' is not explicitly defined. - // state.children = []; - // if (parent) parent.children.push(state); - parent: function(state) { - if (isDefined(state.parent) && state.parent) return findState(state.parent); - // regex matches any valid composite state name - // would match "contact.list" but not "contacts" - var compositeName = /^(.+)\.[^.]+$/.exec(state.name); - return compositeName ? findState(compositeName[1]) : root; - }, - - // inherit 'data' from parent and override by own values (if any) - data: function(state) { - if (state.parent && state.parent.data) { - state.data = state.self.data = extend({}, state.parent.data, state.data); - } - return state.data; - }, - - // Build a URLMatcher if necessary, either via a relative or absolute URL - url: function(state) { - var url = state.url, config = { params: state.params || {} }; - - if (isString(url)) { - if (url.charAt(0) == '^') return $urlMatcherFactory.compile(url.substring(1), config); - return (state.parent.navigable || root).url.concat(url, config); - } - - if (!url || $urlMatcherFactory.isMatcher(url)) return url; - throw new Error("Invalid url '" + url + "' in state '" + state + "'"); - }, - - // Keep track of the closest ancestor state that has a URL (i.e. is navigable) - navigable: function(state) { - return state.url ? state : (state.parent ? state.parent.navigable : null); - }, - - // Own parameters for this state. state.url.params is already built at this point. Create and add non-url params - ownParams: function(state) { - var params = state.url && state.url.params || new $$UMFP.ParamSet(); - forEach(state.params || {}, function(config, id) { - if (!params[id]) params[id] = new $$UMFP.Param(id, null, config, "config"); - }); - return params; - }, - - // Derive parameters for this state and ensure they're a super-set of parent's parameters - params: function(state) { - return state.parent && state.parent.params ? extend(state.parent.params.$$new(), state.ownParams) : new $$UMFP.ParamSet(); - }, - - // If there is no explicit multi-view configuration, make one up so we don't have - // to handle both cases in the view directive later. Note that having an explicit - // 'views' property will mean the default unnamed view properties are ignored. This - // is also a good time to resolve view names to absolute names, so everything is a - // straight lookup at link time. - views: function(state) { - var views = {}; - - forEach(isDefined(state.views) ? state.views : { '': state }, function (view, name) { - if (name.indexOf('@') < 0) name += '@' + state.parent.name; - views[name] = view; - }); - return views; - }, - - // Keep a full path from the root down to this state as this is needed for state activation. - path: function(state) { - return state.parent ? state.parent.path.concat(state) : []; // exclude root from path - }, - - // Speed up $state.contains() as it's used a lot - includes: function(state) { - var includes = state.parent ? extend({}, state.parent.includes) : {}; - includes[state.name] = true; - return includes; - }, - - $delegates: {} - }; - - function isRelative(stateName) { - return stateName.indexOf(".") === 0 || stateName.indexOf("^") === 0; - } - - function findState(stateOrName, base) { - if (!stateOrName) return undefined; - - var isStr = isString(stateOrName), - name = isStr ? stateOrName : stateOrName.name, - path = isRelative(name); - - if (path) { - if (!base) throw new Error("No reference point given for path '" + name + "'"); - base = findState(base); - - var rel = name.split("."), i = 0, pathLength = rel.length, current = base; - - for (; i < pathLength; i++) { - if (rel[i] === "" && i === 0) { - current = base; - continue; - } - if (rel[i] === "^") { - if (!current.parent) throw new Error("Path '" + name + "' not valid for state '" + base.name + "'"); - current = current.parent; - continue; - } - break; - } - rel = rel.slice(i).join("."); - name = current.name + (current.name && rel ? "." : "") + rel; - } - var state = states[name]; - - if (state && (isStr || (!isStr && (state === stateOrName || state.self === stateOrName)))) { - return state; - } - return undefined; - } - - function queueState(parentName, state) { - if (!queue[parentName]) { - queue[parentName] = []; - } - queue[parentName].push(state); - } - - function flushQueuedChildren(parentName) { - var queued = queue[parentName] || []; - while(queued.length) { - registerState(queued.shift()); - } - } - - function registerState(state) { - // Wrap a new object around the state so we can store our private details easily. - state = inherit(state, { - self: state, - resolve: state.resolve || {}, - toString: function() { return this.name; } - }); - - var name = state.name; - if (!isString(name) || name.indexOf('@') >= 0) throw new Error("State must have a valid name"); - if (states.hasOwnProperty(name)) throw new Error("State '" + name + "'' is already defined"); - - // Get parent name - var parentName = (name.indexOf('.') !== -1) ? name.substring(0, name.lastIndexOf('.')) - : (isString(state.parent)) ? state.parent - : (isObject(state.parent) && isString(state.parent.name)) ? state.parent.name - : ''; - - // If parent is not registered yet, add state to queue and register later - if (parentName && !states[parentName]) { - return queueState(parentName, state.self); - } - - for (var key in stateBuilder) { - if (isFunction(stateBuilder[key])) state[key] = stateBuilder[key](state, stateBuilder.$delegates[key]); - } - states[name] = state; - - // Register the state in the global state list and with $urlRouter if necessary. - if (!state[abstractKey] && state.url) { - $urlRouterProvider.when(state.url, ['$match', '$stateParams', function ($match, $stateParams) { - if ($state.$current.navigable != state || !equalForKeys($match, $stateParams)) { - $state.transitionTo(state, $match, { inherit: true, location: false }); - } - }]); - } - - // Register any queued children - flushQueuedChildren(name); - - return state; - } - - // Checks text to see if it looks like a glob. - function isGlob (text) { - return text.indexOf('*') > -1; - } - - // Returns true if glob matches current $state name. - function doesStateMatchGlob (glob) { - var globSegments = glob.split('.'), - segments = $state.$current.name.split('.'); - - //match greedy starts - if (globSegments[0] === '**') { - segments = segments.slice(indexOf(segments, globSegments[1])); - segments.unshift('**'); - } - //match greedy ends - if (globSegments[globSegments.length - 1] === '**') { - segments.splice(indexOf(segments, globSegments[globSegments.length - 2]) + 1, Number.MAX_VALUE); - segments.push('**'); - } - - if (globSegments.length != segments.length) { - return false; - } - - //match single stars - for (var i = 0, l = globSegments.length; i < l; i++) { - if (globSegments[i] === '*') { - segments[i] = '*'; - } - } - - return segments.join('') === globSegments.join(''); - } - - - // Implicit root state that is always active - root = registerState({ - name: '', - url: '^', - views: null, - 'abstract': true - }); - root.navigable = null; - - - /** - * @ngdoc function - * @name ui.router.state.$stateProvider#decorator - * @methodOf ui.router.state.$stateProvider - * - * @description - * Allows you to extend (carefully) or override (at your own peril) the - * `stateBuilder` object used internally by `$stateProvider`. This can be used - * to add custom functionality to ui-router, for example inferring templateUrl - * based on the state name. - * - * When passing only a name, it returns the current (original or decorated) builder - * function that matches `name`. - * - * The builder functions that can be decorated are listed below. Though not all - * necessarily have a good use case for decoration, that is up to you to decide. - * - * In addition, users can attach custom decorators, which will generate new - * properties within the state's internal definition. There is currently no clear - * use-case for this beyond accessing internal states (i.e. $state.$current), - * however, expect this to become increasingly relevant as we introduce additional - * meta-programming features. - * - * **Warning**: Decorators should not be interdependent because the order of - * execution of the builder functions in non-deterministic. Builder functions - * should only be dependent on the state definition object and super function. - * - * - * Existing builder functions and current return values: - * - * - **parent** `{object}` - returns the parent state object. - * - **data** `{object}` - returns state data, including any inherited data that is not - * overridden by own values (if any). - * - **url** `{object}` - returns a {@link ui.router.util.type:UrlMatcher UrlMatcher} - * or `null`. - * - **navigable** `{object}` - returns closest ancestor state that has a URL (aka is - * navigable). - * - **params** `{object}` - returns an array of state params that are ensured to - * be a super-set of parent's params. - * - **views** `{object}` - returns a views object where each key is an absolute view - * name (i.e. "viewName@stateName") and each value is the config object - * (template, controller) for the view. Even when you don't use the views object - * explicitly on a state config, one is still created for you internally. - * So by decorating this builder function you have access to decorating template - * and controller properties. - * - **ownParams** `{object}` - returns an array of params that belong to the state, - * not including any params defined by ancestor states. - * - **path** `{string}` - returns the full path from the root down to this state. - * Needed for state activation. - * - **includes** `{object}` - returns an object that includes every state that - * would pass a `$state.includes()` test. - * - * @example - *
    -     * // Override the internal 'views' builder with a function that takes the state
    -     * // definition, and a reference to the internal function being overridden:
    -     * $stateProvider.decorator('views', function (state, parent) {
    -     *   var result = {},
    -     *       views = parent(state);
    -     *
    -     *   angular.forEach(views, function (config, name) {
    -     *     var autoName = (state.name + '.' + name).replace('.', '/');
    -     *     config.templateUrl = config.templateUrl || '/partials/' + autoName + '.html';
    -     *     result[name] = config;
    -     *   });
    -     *   return result;
    -     * });
    -     *
    -     * $stateProvider.state('home', {
    -     *   views: {
    -     *     'contact.list': { controller: 'ListController' },
    -     *     'contact.item': { controller: 'ItemController' }
    -     *   }
    -     * });
    -     *
    -     * // ...
    -     *
    -     * $state.go('home');
    -     * // Auto-populates list and item views with /partials/home/contact/list.html,
    -     * // and /partials/home/contact/item.html, respectively.
    -     * 
    - * - * @param {string} name The name of the builder function to decorate. - * @param {object} func A function that is responsible for decorating the original - * builder function. The function receives two parameters: - * - * - `{object}` - state - The state config object. - * - `{object}` - super - The original builder function. - * - * @return {object} $stateProvider - $stateProvider instance - */ - this.decorator = decorator; - function decorator(name, func) { - /*jshint validthis: true */ - if (isString(name) && !isDefined(func)) { - return stateBuilder[name]; - } - if (!isFunction(func) || !isString(name)) { - return this; - } - if (stateBuilder[name] && !stateBuilder.$delegates[name]) { - stateBuilder.$delegates[name] = stateBuilder[name]; - } - stateBuilder[name] = func; - return this; - } - - /** - * @ngdoc function - * @name ui.router.state.$stateProvider#state - * @methodOf ui.router.state.$stateProvider - * - * @description - * Registers a state configuration under a given state name. The stateConfig object - * has the following acceptable properties. - * - * @param {string} name A unique state name, e.g. "home", "about", "contacts". - * To create a parent/child state use a dot, e.g. "about.sales", "home.newest". - * @param {object} stateConfig State configuration object. - * @param {string|function=} stateConfig.template - * - * html template as a string or a function that returns - * an html template as a string which should be used by the uiView directives. This property - * takes precedence over templateUrl. - * - * If `template` is a function, it will be called with the following parameters: - * - * - {array.<object>} - state parameters extracted from the current $location.path() by - * applying the current state - * - *
    template:
    -     *   "

    inline template definition

    " + - * "
    "
    - *
    template: function(params) {
    -     *       return "

    generated template

    "; }
    - *
    - * - * @param {string|function=} stateConfig.templateUrl - * - * - * path or function that returns a path to an html - * template that should be used by uiView. - * - * If `templateUrl` is a function, it will be called with the following parameters: - * - * - {array.<object>} - state parameters extracted from the current $location.path() by - * applying the current state - * - *
    templateUrl: "home.html"
    - *
    templateUrl: function(params) {
    -     *     return myTemplates[params.pageId]; }
    - * - * @param {function=} stateConfig.templateProvider - * - * Provider function that returns HTML content string. - *
     templateProvider:
    -     *       function(MyTemplateService, params) {
    -     *         return MyTemplateService.getTemplate(params.pageId);
    -     *       }
    - * - * @param {string|function=} stateConfig.controller - * - * - * Controller fn that should be associated with newly - * related scope or the name of a registered controller if passed as a string. - * Optionally, the ControllerAs may be declared here. - *
    controller: "MyRegisteredController"
    - *
    controller:
    -     *     "MyRegisteredController as fooCtrl"}
    - *
    controller: function($scope, MyService) {
    -     *     $scope.data = MyService.getData(); }
    - * - * @param {function=} stateConfig.controllerProvider - * - * - * Injectable provider function that returns the actual controller or string. - *
    controllerProvider:
    -     *   function(MyResolveData) {
    -     *     if (MyResolveData.foo)
    -     *       return "FooCtrl"
    -     *     else if (MyResolveData.bar)
    -     *       return "BarCtrl";
    -     *     else return function($scope) {
    -     *       $scope.baz = "Qux";
    -     *     }
    -     *   }
    - * - * @param {string=} stateConfig.controllerAs - * - * - * A controller alias name. If present the controller will be - * published to scope under the controllerAs name. - *
    controllerAs: "myCtrl"
    - * - * @param {object=} stateConfig.resolve - * - * - * An optional map<string, function> of dependencies which - * should be injected into the controller. If any of these dependencies are promises, - * the router will wait for them all to be resolved before the controller is instantiated. - * If all the promises are resolved successfully, the $stateChangeSuccess event is fired - * and the values of the resolved promises are injected into any controllers that reference them. - * If any of the promises are rejected the $stateChangeError event is fired. - * - * The map object is: - * - * - key - {string}: name of dependency to be injected into controller - * - factory - {string|function}: If string then it is alias for service. Otherwise if function, - * it is injected and return value it treated as dependency. If result is a promise, it is - * resolved before its value is injected into controller. - * - *
    resolve: {
    -     *     myResolve1:
    -     *       function($http, $stateParams) {
    -     *         return $http.get("/api/foos/"+stateParams.fooID);
    -     *       }
    -     *     }
    - * - * @param {string=} stateConfig.url - * - * - * A url fragment with optional parameters. When a state is navigated or - * transitioned to, the `$stateParams` service will be populated with any - * parameters that were passed. - * - * examples: - *
    url: "/home"
    -     * url: "/users/:userid"
    -     * url: "/books/{bookid:[a-zA-Z_-]}"
    -     * url: "/books/{categoryid:int}"
    -     * url: "/books/{publishername:string}/{categoryid:int}"
    -     * url: "/messages?before&after"
    -     * url: "/messages?{before:date}&{after:date}"
    - * url: "/messages/:mailboxid?{before:date}&{after:date}" - * - * @param {object=} stateConfig.views - * - * an optional map<string, object> which defined multiple views, or targets views - * manually/explicitly. - * - * Examples: - * - * Targets three named `ui-view`s in the parent state's template - *
    views: {
    -     *     header: {
    -     *       controller: "headerCtrl",
    -     *       templateUrl: "header.html"
    -     *     }, body: {
    -     *       controller: "bodyCtrl",
    -     *       templateUrl: "body.html"
    -     *     }, footer: {
    -     *       controller: "footCtrl",
    -     *       templateUrl: "footer.html"
    -     *     }
    -     *   }
    - * - * Targets named `ui-view="header"` from grandparent state 'top''s template, and named `ui-view="body" from parent state's template. - *
    views: {
    -     *     'header@top': {
    -     *       controller: "msgHeaderCtrl",
    -     *       templateUrl: "msgHeader.html"
    -     *     }, 'body': {
    -     *       controller: "messagesCtrl",
    -     *       templateUrl: "messages.html"
    -     *     }
    -     *   }
    - * - * @param {boolean=} [stateConfig.abstract=false] - * - * An abstract state will never be directly activated, - * but can provide inherited properties to its common children states. - *
    abstract: true
    - * - * @param {function=} stateConfig.onEnter - * - * - * Callback function for when a state is entered. Good way - * to trigger an action or dispatch an event, such as opening a dialog. - * If minifying your scripts, make sure to explictly annotate this function, - * because it won't be automatically annotated by your build tools. - * - *
    onEnter: function(MyService, $stateParams) {
    -     *     MyService.foo($stateParams.myParam);
    -     * }
    - * - * @param {function=} stateConfig.onExit - * - * - * Callback function for when a state is exited. Good way to - * trigger an action or dispatch an event, such as opening a dialog. - * If minifying your scripts, make sure to explictly annotate this function, - * because it won't be automatically annotated by your build tools. - * - *
    onExit: function(MyService, $stateParams) {
    -     *     MyService.cleanup($stateParams.myParam);
    -     * }
    - * - * @param {boolean=} [stateConfig.reloadOnSearch=true] - * - * - * If `false`, will not retrigger the same state - * just because a search/query parameter has changed (via $location.search() or $location.hash()). - * Useful for when you'd like to modify $location.search() without triggering a reload. - *
    reloadOnSearch: false
    - * - * @param {object=} stateConfig.data - * - * - * Arbitrary data object, useful for custom configuration. The parent state's `data` is - * prototypally inherited. In other words, adding a data property to a state adds it to - * the entire subtree via prototypal inheritance. - * - *
    data: {
    -     *     requiredRole: 'foo'
    -     * } 
    - * - * @param {object=} stateConfig.params - * - * - * A map which optionally configures parameters declared in the `url`, or - * defines additional non-url parameters. For each parameter being - * configured, add a configuration object keyed to the name of the parameter. - * - * Each parameter configuration object may contain the following properties: - * - * - ** value ** - {object|function=}: specifies the default value for this - * parameter. This implicitly sets this parameter as optional. - * - * When UI-Router routes to a state and no value is - * specified for this parameter in the URL or transition, the - * default value will be used instead. If `value` is a function, - * it will be injected and invoked, and the return value used. - * - * *Note*: `undefined` is treated as "no default value" while `null` - * is treated as "the default value is `null`". - * - * *Shorthand*: If you only need to configure the default value of the - * parameter, you may use a shorthand syntax. In the **`params`** - * map, instead mapping the param name to a full parameter configuration - * object, simply set map it to the default parameter value, e.g.: - * - *
    // define a parameter's default value
    -     * params: {
    -     *     param1: { value: "defaultValue" }
    -     * }
    -     * // shorthand default values
    -     * params: {
    -     *     param1: "defaultValue",
    -     *     param2: "param2Default"
    -     * }
    - * - * - ** array ** - {boolean=}: *(default: false)* If true, the param value will be - * treated as an array of values. If you specified a Type, the value will be - * treated as an array of the specified Type. Note: query parameter values - * default to a special `"auto"` mode. - * - * For query parameters in `"auto"` mode, if multiple values for a single parameter - * are present in the URL (e.g.: `/foo?bar=1&bar=2&bar=3`) then the values - * are mapped to an array (e.g.: `{ foo: [ '1', '2', '3' ] }`). However, if - * only one value is present (e.g.: `/foo?bar=1`) then the value is treated as single - * value (e.g.: `{ foo: '1' }`). - * - *
    params: {
    -     *     param1: { array: true }
    -     * }
    - * - * - ** squash ** - {bool|string=}: `squash` configures how a default parameter value is represented in the URL when - * the current parameter value is the same as the default value. If `squash` is not set, it uses the - * configured default squash policy. - * (See {@link ui.router.util.$urlMatcherFactory#methods_defaultSquashPolicy `defaultSquashPolicy()`}) - * - * There are three squash settings: - * - * - false: The parameter's default value is not squashed. It is encoded and included in the URL - * - true: The parameter's default value is omitted from the URL. If the parameter is preceeded and followed - * by slashes in the state's `url` declaration, then one of those slashes are omitted. - * This can allow for cleaner looking URLs. - * - `""`: The parameter's default value is replaced with an arbitrary placeholder of your choice. - * - *
    params: {
    -     *     param1: {
    -     *       value: "defaultId",
    -     *       squash: true
    -     * } }
    -     * // squash "defaultValue" to "~"
    -     * params: {
    -     *     param1: {
    -     *       value: "defaultValue",
    -     *       squash: "~"
    -     * } }
    -     * 
    - * - * - * @example - *
    -     * // Some state name examples
    -     *
    -     * // stateName can be a single top-level name (must be unique).
    -     * $stateProvider.state("home", {});
    -     *
    -     * // Or it can be a nested state name. This state is a child of the
    -     * // above "home" state.
    -     * $stateProvider.state("home.newest", {});
    -     *
    -     * // Nest states as deeply as needed.
    -     * $stateProvider.state("home.newest.abc.xyz.inception", {});
    -     *
    -     * // state() returns $stateProvider, so you can chain state declarations.
    -     * $stateProvider
    -     *   .state("home", {})
    -     *   .state("about", {})
    -     *   .state("contacts", {});
    -     * 
    - * - */ - this.state = state; - function state(name, definition) { - /*jshint validthis: true */ - if (isObject(name)) definition = name; - else definition.name = name; - registerState(definition); - return this; - } - - /** - * @ngdoc object - * @name ui.router.state.$state - * - * @requires $rootScope - * @requires $q - * @requires ui.router.state.$view - * @requires $injector - * @requires ui.router.util.$resolve - * @requires ui.router.state.$stateParams - * @requires ui.router.router.$urlRouter - * - * @property {object} params A param object, e.g. {sectionId: section.id)}, that - * you'd like to test against the current active state. - * @property {object} current A reference to the state's config object. However - * you passed it in. Useful for accessing custom data. - * @property {object} transition Currently pending transition. A promise that'll - * resolve or reject. - * - * @description - * `$state` service is responsible for representing states as well as transitioning - * between them. It also provides interfaces to ask for current state or even states - * you're coming from. - */ - this.$get = $get; - $get.$inject = ['$rootScope', '$q', '$view', '$injector', '$resolve', '$stateParams', '$urlRouter', '$location', '$urlMatcherFactory']; - function $get( $rootScope, $q, $view, $injector, $resolve, $stateParams, $urlRouter, $location, $urlMatcherFactory) { - - var TransitionSuperseded = $q.reject(new Error('transition superseded')); - var TransitionPrevented = $q.reject(new Error('transition prevented')); - var TransitionAborted = $q.reject(new Error('transition aborted')); - var TransitionFailed = $q.reject(new Error('transition failed')); - - // Handles the case where a state which is the target of a transition is not found, and the user - // can optionally retry or defer the transition - function handleRedirect(redirect, state, params, options) { - /** - * @ngdoc event - * @name ui.router.state.$state#$stateNotFound - * @eventOf ui.router.state.$state - * @eventType broadcast on root scope - * @description - * Fired when a requested state **cannot be found** using the provided state name during transition. - * The event is broadcast allowing any handlers a single chance to deal with the error (usually by - * lazy-loading the unfound state). A special `unfoundState` object is passed to the listener handler, - * you can see its three properties in the example. You can use `event.preventDefault()` to abort the - * transition and the promise returned from `go` will be rejected with a `'transition aborted'` value. - * - * @param {Object} event Event object. - * @param {Object} unfoundState Unfound State information. Contains: `to, toParams, options` properties. - * @param {State} fromState Current state object. - * @param {Object} fromParams Current state params. - * - * @example - * - *
    -         * // somewhere, assume lazy.state has not been defined
    -         * $state.go("lazy.state", {a:1, b:2}, {inherit:false});
    -         *
    -         * // somewhere else
    -         * $scope.$on('$stateNotFound',
    -         * function(event, unfoundState, fromState, fromParams){
    -         *     console.log(unfoundState.to); // "lazy.state"
    -         *     console.log(unfoundState.toParams); // {a:1, b:2}
    -         *     console.log(unfoundState.options); // {inherit:false} + default options
    -         * })
    -         * 
    - */ - var evt = $rootScope.$broadcast('$stateNotFound', redirect, state, params); - - if (evt.defaultPrevented) { - $urlRouter.update(); - return TransitionAborted; - } - - if (!evt.retry) { - return null; - } - - // Allow the handler to return a promise to defer state lookup retry - if (options.$retry) { - $urlRouter.update(); - return TransitionFailed; - } - var retryTransition = $state.transition = $q.when(evt.retry); - - retryTransition.then(function() { - if (retryTransition !== $state.transition) return TransitionSuperseded; - redirect.options.$retry = true; - return $state.transitionTo(redirect.to, redirect.toParams, redirect.options); - }, function() { - return TransitionAborted; - }); - $urlRouter.update(); - - return retryTransition; - } - - root.locals = { resolve: null, globals: { $stateParams: {} } }; - - $state = { - params: {}, - current: root.self, - $current: root, - transition: null - }; - - /** - * @ngdoc function - * @name ui.router.state.$state#reload - * @methodOf ui.router.state.$state - * - * @description - * A method that force reloads the current state. All resolves are re-resolved, events are not re-fired, - * and controllers reinstantiated (bug with controllers reinstantiating right now, fixing soon). - * - * @example - *
    -       * var app angular.module('app', ['ui.router']);
    -       *
    -       * app.controller('ctrl', function ($scope, $state) {
    -       *   $scope.reload = function(){
    -       *     $state.reload();
    -       *   }
    -       * });
    -       * 
    - * - * `reload()` is just an alias for: - *
    -       * $state.transitionTo($state.current, $stateParams, { 
    -       *   reload: true, inherit: false, notify: true
    -       * });
    -       * 
    - * - * @returns {promise} A promise representing the state of the new transition. See - * {@link ui.router.state.$state#methods_go $state.go}. - */ - $state.reload = function reload() { - return $state.transitionTo($state.current, $stateParams, { reload: true, inherit: false, notify: true }); - }; - - /** - * @ngdoc function - * @name ui.router.state.$state#go - * @methodOf ui.router.state.$state - * - * @description - * Convenience method for transitioning to a new state. `$state.go` calls - * `$state.transitionTo` internally but automatically sets options to - * `{ location: true, inherit: true, relative: $state.$current, notify: true }`. - * This allows you to easily use an absolute or relative to path and specify - * only the parameters you'd like to update (while letting unspecified parameters - * inherit from the currently active ancestor states). - * - * @example - *
    -       * var app = angular.module('app', ['ui.router']);
    -       *
    -       * app.controller('ctrl', function ($scope, $state) {
    -       *   $scope.changeState = function () {
    -       *     $state.go('contact.detail');
    -       *   };
    -       * });
    -       * 
    - * - * - * @param {string} to Absolute state name or relative state path. Some examples: - * - * - `$state.go('contact.detail')` - will go to the `contact.detail` state - * - `$state.go('^')` - will go to a parent state - * - `$state.go('^.sibling')` - will go to a sibling state - * - `$state.go('.child.grandchild')` - will go to grandchild state - * - * @param {object=} params A map of the parameters that will be sent to the state, - * will populate $stateParams. Any parameters that are not specified will be inherited from currently - * defined parameters. This allows, for example, going to a sibling state that shares parameters - * specified in a parent state. Parameter inheritance only works between common ancestor states, I.e. - * transitioning to a sibling will get you the parameters for all parents, transitioning to a child - * will get you all current parameters, etc. - * @param {object=} options Options object. The options are: - * - * - **`location`** - {boolean=true|string=} - If `true` will update the url in the location bar, if `false` - * will not. If string, must be `"replace"`, which will update url and also replace last history record. - * - **`inherit`** - {boolean=true}, If `true` will inherit url parameters from current url. - * - **`relative`** - {object=$state.$current}, When transitioning with relative path (e.g '^'), - * defines which state to be relative from. - * - **`notify`** - {boolean=true}, If `true` will broadcast $stateChangeStart and $stateChangeSuccess events. - * - **`reload`** (v0.2.5) - {boolean=false}, If `true` will force transition even if the state or params - * have not changed, aka a reload of the same state. It differs from reloadOnSearch because you'd - * use this when you want to force a reload when *everything* is the same, including search params. - * - * @returns {promise} A promise representing the state of the new transition. - * - * Possible success values: - * - * - $state.current - * - *
    Possible rejection values: - * - * - 'transition superseded' - when a newer transition has been started after this one - * - 'transition prevented' - when `event.preventDefault()` has been called in a `$stateChangeStart` listener - * - 'transition aborted' - when `event.preventDefault()` has been called in a `$stateNotFound` listener or - * when a `$stateNotFound` `event.retry` promise errors. - * - 'transition failed' - when a state has been unsuccessfully found after 2 tries. - * - *resolve error* - when an error has occurred with a `resolve` - * - */ - $state.go = function go(to, params, options) { - return $state.transitionTo(to, params, extend({ inherit: true, relative: $state.$current }, options)); - }; - - /** - * @ngdoc function - * @name ui.router.state.$state#transitionTo - * @methodOf ui.router.state.$state - * - * @description - * Low-level method for transitioning to a new state. {@link ui.router.state.$state#methods_go $state.go} - * uses `transitionTo` internally. `$state.go` is recommended in most situations. - * - * @example - *
    -       * var app = angular.module('app', ['ui.router']);
    -       *
    -       * app.controller('ctrl', function ($scope, $state) {
    -       *   $scope.changeState = function () {
    -       *     $state.transitionTo('contact.detail');
    -       *   };
    -       * });
    -       * 
    - * - * @param {string} to State name. - * @param {object=} toParams A map of the parameters that will be sent to the state, - * will populate $stateParams. - * @param {object=} options Options object. The options are: - * - * - **`location`** - {boolean=true|string=} - If `true` will update the url in the location bar, if `false` - * will not. If string, must be `"replace"`, which will update url and also replace last history record. - * - **`inherit`** - {boolean=false}, If `true` will inherit url parameters from current url. - * - **`relative`** - {object=}, When transitioning with relative path (e.g '^'), - * defines which state to be relative from. - * - **`notify`** - {boolean=true}, If `true` will broadcast $stateChangeStart and $stateChangeSuccess events. - * - **`reload`** (v0.2.5) - {boolean=false}, If `true` will force transition even if the state or params - * have not changed, aka a reload of the same state. It differs from reloadOnSearch because you'd - * use this when you want to force a reload when *everything* is the same, including search params. - * - * @returns {promise} A promise representing the state of the new transition. See - * {@link ui.router.state.$state#methods_go $state.go}. - */ - $state.transitionTo = function transitionTo(to, toParams, options) { - toParams = toParams || {}; - options = extend({ - location: true, inherit: false, relative: null, notify: true, reload: false, $retry: false - }, options || {}); - - var from = $state.$current, fromParams = $state.params, fromPath = from.path; - var evt, toState = findState(to, options.relative); - - if (!isDefined(toState)) { - var redirect = { to: to, toParams: toParams, options: options }; - var redirectResult = handleRedirect(redirect, from.self, fromParams, options); - - if (redirectResult) { - return redirectResult; - } - - // Always retry once if the $stateNotFound was not prevented - // (handles either redirect changed or state lazy-definition) - to = redirect.to; - toParams = redirect.toParams; - options = redirect.options; - toState = findState(to, options.relative); - - if (!isDefined(toState)) { - if (!options.relative) throw new Error("No such state '" + to + "'"); - throw new Error("Could not resolve '" + to + "' from state '" + options.relative + "'"); - } - } - if (toState[abstractKey]) throw new Error("Cannot transition to abstract state '" + to + "'"); - if (options.inherit) toParams = inheritParams($stateParams, toParams || {}, $state.$current, toState); - if (!toState.params.$$validates(toParams)) return TransitionFailed; - - toParams = toState.params.$$values(toParams); - to = toState; - - var toPath = to.path; - - // Starting from the root of the path, keep all levels that haven't changed - var keep = 0, state = toPath[keep], locals = root.locals, toLocals = []; - - if (!options.reload) { - while (state && state === fromPath[keep] && state.ownParams.$$equals(toParams, fromParams)) { - locals = toLocals[keep] = state.locals; - keep++; - state = toPath[keep]; - } - } - - // If we're going to the same state and all locals are kept, we've got nothing to do. - // But clear 'transition', as we still want to cancel any other pending transitions. - // TODO: We may not want to bump 'transition' if we're called from a location change - // that we've initiated ourselves, because we might accidentally abort a legitimate - // transition initiated from code? - if (shouldTriggerReload(to, from, locals, options)) { - if (to.self.reloadOnSearch !== false) $urlRouter.update(); - $state.transition = null; - return $q.when($state.current); - } - - // Filter parameters before we pass them to event handlers etc. - toParams = filterByKeys(to.params.$$keys(), toParams || {}); - - // Broadcast start event and cancel the transition if requested - if (options.notify) { - /** - * @ngdoc event - * @name ui.router.state.$state#$stateChangeStart - * @eventOf ui.router.state.$state - * @eventType broadcast on root scope - * @description - * Fired when the state transition **begins**. You can use `event.preventDefault()` - * to prevent the transition from happening and then the transition promise will be - * rejected with a `'transition prevented'` value. - * - * @param {Object} event Event object. - * @param {State} toState The state being transitioned to. - * @param {Object} toParams The params supplied to the `toState`. - * @param {State} fromState The current state, pre-transition. - * @param {Object} fromParams The params supplied to the `fromState`. - * - * @example - * - *
    -           * $rootScope.$on('$stateChangeStart',
    -           * function(event, toState, toParams, fromState, fromParams){
    -           *     event.preventDefault();
    -           *     // transitionTo() promise will be rejected with
    -           *     // a 'transition prevented' error
    -           * })
    -           * 
    - */ - if ($rootScope.$broadcast('$stateChangeStart', to.self, toParams, from.self, fromParams).defaultPrevented) { - $urlRouter.update(); - return TransitionPrevented; - } - } - - // Resolve locals for the remaining states, but don't update any global state just - // yet -- if anything fails to resolve the current state needs to remain untouched. - // We also set up an inheritance chain for the locals here. This allows the view directive - // to quickly look up the correct definition for each view in the current state. Even - // though we create the locals object itself outside resolveState(), it is initially - // empty and gets filled asynchronously. We need to keep track of the promise for the - // (fully resolved) current locals, and pass this down the chain. - var resolved = $q.when(locals); - - for (var l = keep; l < toPath.length; l++, state = toPath[l]) { - locals = toLocals[l] = inherit(locals); - resolved = resolveState(state, toParams, state === to, resolved, locals, options); - } - - // Once everything is resolved, we are ready to perform the actual transition - // and return a promise for the new state. We also keep track of what the - // current promise is, so that we can detect overlapping transitions and - // keep only the outcome of the last transition. - var transition = $state.transition = resolved.then(function () { - var l, entering, exiting; - - if ($state.transition !== transition) return TransitionSuperseded; - - // Exit 'from' states not kept - for (l = fromPath.length - 1; l >= keep; l--) { - exiting = fromPath[l]; - if (exiting.self.onExit) { - $injector.invoke(exiting.self.onExit, exiting.self, exiting.locals.globals); - } - exiting.locals = null; - } - - // Enter 'to' states not kept - for (l = keep; l < toPath.length; l++) { - entering = toPath[l]; - entering.locals = toLocals[l]; - if (entering.self.onEnter) { - $injector.invoke(entering.self.onEnter, entering.self, entering.locals.globals); - } - } - - // Run it again, to catch any transitions in callbacks - if ($state.transition !== transition) return TransitionSuperseded; - - // Update globals in $state - $state.$current = to; - $state.current = to.self; - $state.params = toParams; - copy($state.params, $stateParams); - $state.transition = null; - - if (options.location && to.navigable) { - $urlRouter.push(to.navigable.url, to.navigable.locals.globals.$stateParams, { - $$avoidResync: true, replace: options.location === 'replace' - }); - } - - if (options.notify) { - /** - * @ngdoc event - * @name ui.router.state.$state#$stateChangeSuccess - * @eventOf ui.router.state.$state - * @eventType broadcast on root scope - * @description - * Fired once the state transition is **complete**. - * - * @param {Object} event Event object. - * @param {State} toState The state being transitioned to. - * @param {Object} toParams The params supplied to the `toState`. - * @param {State} fromState The current state, pre-transition. - * @param {Object} fromParams The params supplied to the `fromState`. - */ - $rootScope.$broadcast('$stateChangeSuccess', to.self, toParams, from.self, fromParams); - } - $urlRouter.update(true); - - return $state.current; - }, function (error) { - if ($state.transition !== transition) return TransitionSuperseded; - - $state.transition = null; - /** - * @ngdoc event - * @name ui.router.state.$state#$stateChangeError - * @eventOf ui.router.state.$state - * @eventType broadcast on root scope - * @description - * Fired when an **error occurs** during transition. It's important to note that if you - * have any errors in your resolve functions (javascript errors, non-existent services, etc) - * they will not throw traditionally. You must listen for this $stateChangeError event to - * catch **ALL** errors. - * - * @param {Object} event Event object. - * @param {State} toState The state being transitioned to. - * @param {Object} toParams The params supplied to the `toState`. - * @param {State} fromState The current state, pre-transition. - * @param {Object} fromParams The params supplied to the `fromState`. - * @param {Error} error The resolve error object. - */ - evt = $rootScope.$broadcast('$stateChangeError', to.self, toParams, from.self, fromParams, error); - - if (!evt.defaultPrevented) { - $urlRouter.update(); - } - - return $q.reject(error); - }); - - return transition; - }; - - /** - * @ngdoc function - * @name ui.router.state.$state#is - * @methodOf ui.router.state.$state - * - * @description - * Similar to {@link ui.router.state.$state#methods_includes $state.includes}, - * but only checks for the full state name. If params is supplied then it will be - * tested for strict equality against the current active params object, so all params - * must match with none missing and no extras. - * - * @example - *
    -       * $state.$current.name = 'contacts.details.item';
    -       *
    -       * // absolute name
    -       * $state.is('contact.details.item'); // returns true
    -       * $state.is(contactDetailItemStateObject); // returns true
    -       *
    -       * // relative name (. and ^), typically from a template
    -       * // E.g. from the 'contacts.details' template
    -       * 
    Item
    - *
    - * - * @param {string|object} stateOrName The state name (absolute or relative) or state object you'd like to check. - * @param {object=} params A param object, e.g. `{sectionId: section.id}`, that you'd like - * to test against the current active state. - * @param {object=} options An options object. The options are: - * - * - **`relative`** - {string|object} - If `stateOrName` is a relative state name and `options.relative` is set, .is will - * test relative to `options.relative` state (or name). - * - * @returns {boolean} Returns true if it is the state. - */ - $state.is = function is(stateOrName, params, options) { - options = extend({ relative: $state.$current }, options || {}); - var state = findState(stateOrName, options.relative); - - if (!isDefined(state)) { return undefined; } - if ($state.$current !== state) { return false; } - return params ? equalForKeys(state.params.$$values(params), $stateParams) : true; - }; - - /** - * @ngdoc function - * @name ui.router.state.$state#includes - * @methodOf ui.router.state.$state - * - * @description - * A method to determine if the current active state is equal to or is the child of the - * state stateName. If any params are passed then they will be tested for a match as well. - * Not all the parameters need to be passed, just the ones you'd like to test for equality. - * - * @example - * Partial and relative names - *
    -       * $state.$current.name = 'contacts.details.item';
    -       *
    -       * // Using partial names
    -       * $state.includes("contacts"); // returns true
    -       * $state.includes("contacts.details"); // returns true
    -       * $state.includes("contacts.details.item"); // returns true
    -       * $state.includes("contacts.list"); // returns false
    -       * $state.includes("about"); // returns false
    -       *
    -       * // Using relative names (. and ^), typically from a template
    -       * // E.g. from the 'contacts.details' template
    -       * 
    Item
    - *
    - * - * Basic globbing patterns - *
    -       * $state.$current.name = 'contacts.details.item.url';
    -       *
    -       * $state.includes("*.details.*.*"); // returns true
    -       * $state.includes("*.details.**"); // returns true
    -       * $state.includes("**.item.**"); // returns true
    -       * $state.includes("*.details.item.url"); // returns true
    -       * $state.includes("*.details.*.url"); // returns true
    -       * $state.includes("*.details.*"); // returns false
    -       * $state.includes("item.**"); // returns false
    -       * 
    - * - * @param {string} stateOrName A partial name, relative name, or glob pattern - * to be searched for within the current state name. - * @param {object=} params A param object, e.g. `{sectionId: section.id}`, - * that you'd like to test against the current active state. - * @param {object=} options An options object. The options are: - * - * - **`relative`** - {string|object=} - If `stateOrName` is a relative state reference and `options.relative` is set, - * .includes will test relative to `options.relative` state (or name). - * - * @returns {boolean} Returns true if it does include the state - */ - $state.includes = function includes(stateOrName, params, options) { - options = extend({ relative: $state.$current }, options || {}); - if (isString(stateOrName) && isGlob(stateOrName)) { - if (!doesStateMatchGlob(stateOrName)) { - return false; - } - stateOrName = $state.$current.name; - } - - var state = findState(stateOrName, options.relative); - if (!isDefined(state)) { return undefined; } - if (!isDefined($state.$current.includes[state.name])) { return false; } - return params ? equalForKeys(state.params.$$values(params), $stateParams, objectKeys(params)) : true; - }; - - - /** - * @ngdoc function - * @name ui.router.state.$state#href - * @methodOf ui.router.state.$state - * - * @description - * A url generation method that returns the compiled url for the given state populated with the given params. - * - * @example - *
    -       * expect($state.href("about.person", { person: "bob" })).toEqual("/about/bob");
    -       * 
    - * - * @param {string|object} stateOrName The state name or state object you'd like to generate a url from. - * @param {object=} params An object of parameter values to fill the state's required parameters. - * @param {object=} options Options object. The options are: - * - * - **`lossy`** - {boolean=true} - If true, and if there is no url associated with the state provided in the - * first parameter, then the constructed href url will be built from the first navigable ancestor (aka - * ancestor with a valid url). - * - **`inherit`** - {boolean=true}, If `true` will inherit url parameters from current url. - * - **`relative`** - {object=$state.$current}, When transitioning with relative path (e.g '^'), - * defines which state to be relative from. - * - **`absolute`** - {boolean=false}, If true will generate an absolute url, e.g. "http://www.example.com/fullurl". - * - * @returns {string} compiled state url - */ - $state.href = function href(stateOrName, params, options) { - options = extend({ - lossy: true, - inherit: true, - absolute: false, - relative: $state.$current - }, options || {}); - - var state = findState(stateOrName, options.relative); - - if (!isDefined(state)) return null; - if (options.inherit) params = inheritParams($stateParams, params || {}, $state.$current, state); - - var nav = (state && options.lossy) ? state.navigable : state; - - if (!nav || nav.url === undefined || nav.url === null) { - return null; - } - return $urlRouter.href(nav.url, filterByKeys(state.params.$$keys(), params || {}), { - absolute: options.absolute - }); - }; - - /** - * @ngdoc function - * @name ui.router.state.$state#get - * @methodOf ui.router.state.$state - * - * @description - * Returns the state configuration object for any specific state or all states. - * - * @param {string|object=} stateOrName (absolute or relative) If provided, will only get the config for - * the requested state. If not provided, returns an array of ALL state configs. - * @param {string|object=} context When stateOrName is a relative state reference, the state will be retrieved relative to context. - * @returns {Object|Array} State configuration object or array of all objects. - */ - $state.get = function (stateOrName, context) { - if (arguments.length === 0) return map(objectKeys(states), function(name) { return states[name].self; }); - var state = findState(stateOrName, context || $state.$current); - return (state && state.self) ? state.self : null; - }; - - function resolveState(state, params, paramsAreFiltered, inherited, dst, options) { - // Make a restricted $stateParams with only the parameters that apply to this state if - // necessary. In addition to being available to the controller and onEnter/onExit callbacks, - // we also need $stateParams to be available for any $injector calls we make during the - // dependency resolution process. - var $stateParams = (paramsAreFiltered) ? params : filterByKeys(state.params.$$keys(), params); - var locals = { $stateParams: $stateParams }; - - // Resolve 'global' dependencies for the state, i.e. those not specific to a view. - // We're also including $stateParams in this; that way the parameters are restricted - // to the set that should be visible to the state, and are independent of when we update - // the global $state and $stateParams values. - dst.resolve = $resolve.resolve(state.resolve, locals, dst.resolve, state); - var promises = [dst.resolve.then(function (globals) { - dst.globals = globals; - })]; - if (inherited) promises.push(inherited); - - // Resolve template and dependencies for all views. - forEach(state.views, function (view, name) { - var injectables = (view.resolve && view.resolve !== state.resolve ? view.resolve : {}); - injectables.$template = [ function () { - return $view.load(name, { view: view, locals: locals, params: $stateParams, notify: options.notify }) || ''; - }]; - - promises.push($resolve.resolve(injectables, locals, dst.resolve, state).then(function (result) { - // References to the controller (only instantiated at link time) - if (isFunction(view.controllerProvider) || isArray(view.controllerProvider)) { - var injectLocals = angular.extend({}, injectables, locals); - result.$$controller = $injector.invoke(view.controllerProvider, null, injectLocals); - } else { - result.$$controller = view.controller; - } - // Provide access to the state itself for internal use - result.$$state = state; - result.$$controllerAs = view.controllerAs; - dst[name] = result; - })); - }); - - // Wait for all the promises and then return the activation object - return $q.all(promises).then(function (values) { - return dst; - }); - } - - return $state; - } - - function shouldTriggerReload(to, from, locals, options) { - if (to === from && ((locals === from.locals && !options.reload) || (to.self.reloadOnSearch === false))) { - return true; - } - } - } - - angular.module('ui.router.state') - .value('$stateParams', {}) - .provider('$state', $StateProvider); - - - $ViewProvider.$inject = []; - function $ViewProvider() { - - this.$get = $get; - /** - * @ngdoc object - * @name ui.router.state.$view - * - * @requires ui.router.util.$templateFactory - * @requires $rootScope - * - * @description - * - */ - $get.$inject = ['$rootScope', '$templateFactory']; - function $get( $rootScope, $templateFactory) { - return { - // $view.load('full.viewName', { template: ..., controller: ..., resolve: ..., async: false, params: ... }) - /** - * @ngdoc function - * @name ui.router.state.$view#load - * @methodOf ui.router.state.$view - * - * @description - * - * @param {string} name name - * @param {object} options option object. - */ - load: function load(name, options) { - var result, defaults = { - template: null, controller: null, view: null, locals: null, notify: true, async: true, params: {} - }; - options = extend(defaults, options); - - if (options.view) { - result = $templateFactory.fromConfig(options.view, options.params, options.locals); - } - if (result && options.notify) { - /** - * @ngdoc event - * @name ui.router.state.$state#$viewContentLoading - * @eventOf ui.router.state.$view - * @eventType broadcast on root scope - * @description - * - * Fired once the view **begins loading**, *before* the DOM is rendered. - * - * @param {Object} event Event object. - * @param {Object} viewConfig The view config properties (template, controller, etc). - * - * @example - * - *
    -           * $scope.$on('$viewContentLoading',
    -           * function(event, viewConfig){
    -           *     // Access to all the view config properties.
    -           *     // and one special property 'targetView'
    -           *     // viewConfig.targetView
    -           * });
    -           * 
    - */ - $rootScope.$broadcast('$viewContentLoading', options); - } - return result; - } - }; - } - } - - angular.module('ui.router.state').provider('$view', $ViewProvider); - - /** - * @ngdoc object - * @name ui.router.state.$uiViewScrollProvider - * - * @description - * Provider that returns the {@link ui.router.state.$uiViewScroll} service function. - */ - function $ViewScrollProvider() { - - var useAnchorScroll = false; - - /** - * @ngdoc function - * @name ui.router.state.$uiViewScrollProvider#useAnchorScroll - * @methodOf ui.router.state.$uiViewScrollProvider - * - * @description - * Reverts back to using the core [`$anchorScroll`](http://docs.angularjs.org/api/ng.$anchorScroll) service for - * scrolling based on the url anchor. - */ - this.useAnchorScroll = function () { - useAnchorScroll = true; - }; - - /** - * @ngdoc object - * @name ui.router.state.$uiViewScroll - * - * @requires $anchorScroll - * @requires $timeout - * - * @description - * When called with a jqLite element, it scrolls the element into view (after a - * `$timeout` so the DOM has time to refresh). - * - * If you prefer to rely on `$anchorScroll` to scroll the view to the anchor, - * this can be enabled by calling {@link ui.router.state.$uiViewScrollProvider#methods_useAnchorScroll `$uiViewScrollProvider.useAnchorScroll()`}. - */ - this.$get = ['$anchorScroll', '$timeout', function ($anchorScroll, $timeout) { - if (useAnchorScroll) { - return $anchorScroll; - } - - return function ($element) { - $timeout(function () { - $element[0].scrollIntoView(); - }, 0, false); - }; - }]; - } - - angular.module('ui.router.state').provider('$uiViewScroll', $ViewScrollProvider); - - /** - * @ngdoc directive - * @name ui.router.state.directive:ui-view - * - * @requires ui.router.state.$state - * @requires $compile - * @requires $controller - * @requires $injector - * @requires ui.router.state.$uiViewScroll - * @requires $document - * - * @restrict ECA - * - * @description - * The ui-view directive tells $state where to place your templates. - * - * @param {string=} name A view name. The name should be unique amongst the other views in the - * same state. You can have views of the same name that live in different states. - * - * @param {string=} autoscroll It allows you to set the scroll behavior of the browser window - * when a view is populated. By default, $anchorScroll is overridden by ui-router's custom scroll - * service, {@link ui.router.state.$uiViewScroll}. This custom service let's you - * scroll ui-view elements into view when they are populated during a state activation. - * - * *Note: To revert back to old [`$anchorScroll`](http://docs.angularjs.org/api/ng.$anchorScroll) - * functionality, call `$uiViewScrollProvider.useAnchorScroll()`.* - * - * @param {string=} onload Expression to evaluate whenever the view updates. - * - * @example - * A view can be unnamed or named. - *
    -   * 
    -   * 
    - * - * - *
    - *
    - * - * You can only have one unnamed view within any template (or root html). If you are only using a - * single view and it is unnamed then you can populate it like so: - *
    -   * 
    - * $stateProvider.state("home", { - * template: "

    HELLO!

    " - * }) - *
    - * - * The above is a convenient shortcut equivalent to specifying your view explicitly with the {@link ui.router.state.$stateProvider#views `views`} - * config property, by name, in this case an empty name: - *
    -   * $stateProvider.state("home", {
    -   *   views: {
    -   *     "": {
    -   *       template: "

    HELLO!

    " - * } - * } - * }) - *
    - * - * But typically you'll only use the views property if you name your view or have more than one view - * in the same template. There's not really a compelling reason to name a view if its the only one, - * but you could if you wanted, like so: - *
    -   * 
    - *
    - *
    -   * $stateProvider.state("home", {
    -   *   views: {
    -   *     "main": {
    -   *       template: "

    HELLO!

    " - * } - * } - * }) - *
    - * - * Really though, you'll use views to set up multiple views: - *
    -   * 
    - *
    - *
    - *
    - * - *
    -   * $stateProvider.state("home", {
    -   *   views: {
    -   *     "": {
    -   *       template: "

    HELLO!

    " - * }, - * "chart": { - * template: "" - * }, - * "data": { - * template: "" - * } - * } - * }) - *
    - * - * Examples for `autoscroll`: - * - *
    -   * 
    -   * 
    -   *
    -   * 
    -   * 
    -   * 
    -   * 
    -   * 
    - */ - $ViewDirective.$inject = ['$state', '$injector', '$uiViewScroll', '$interpolate']; - function $ViewDirective( $state, $injector, $uiViewScroll, $interpolate) { - - function getService() { - return ($injector.has) ? function(service) { - return $injector.has(service) ? $injector.get(service) : null; - } : function(service) { - try { - return $injector.get(service); - } catch (e) { - return null; - } - }; - } - - var service = getService(), - $animator = service('$animator'), - $animate = service('$animate'); - - // Returns a set of DOM manipulation functions based on which Angular version - // it should use - function getRenderer(attrs, scope) { - var statics = function() { - return { - enter: function (element, target, cb) { target.after(element); cb(); }, - leave: function (element, cb) { element.remove(); cb(); } - }; - }; - - if ($animate) { - return { - enter: function(element, target, cb) { - var promise = $animate.enter(element, null, target, cb); - if (promise && promise.then) promise.then(cb); - }, - leave: function(element, cb) { - var promise = $animate.leave(element, cb); - if (promise && promise.then) promise.then(cb); - } - }; - } - - if ($animator) { - var animate = $animator && $animator(scope, attrs); - - return { - enter: function(element, target, cb) {animate.enter(element, null, target); cb(); }, - leave: function(element, cb) { animate.leave(element); cb(); } - }; - } - - return statics(); - } - - var directive = { - restrict: 'ECA', - terminal: true, - priority: 400, - transclude: 'element', - compile: function (tElement, tAttrs, $transclude) { - return function (scope, $element, attrs) { - var previousEl, currentEl, currentScope, latestLocals, - onloadExp = attrs.onload || '', - autoScrollExp = attrs.autoscroll, - renderer = getRenderer(attrs, scope); - - scope.$on('$stateChangeSuccess', function() { - updateView(false); - }); - scope.$on('$viewContentLoading', function() { - updateView(false); - }); - - updateView(true); - - function cleanupLastView() { - if (previousEl) { - previousEl.remove(); - previousEl = null; - } - - if (currentScope) { - currentScope.$destroy(); - currentScope = null; - } - - if (currentEl) { - renderer.leave(currentEl, function() { - previousEl = null; - }); - - previousEl = currentEl; - currentEl = null; - } - } - - function updateView(firstTime) { - var newScope, - name = getUiViewName(scope, attrs, $element, $interpolate), - previousLocals = name && $state.$current && $state.$current.locals[name]; - - if (!firstTime && previousLocals === latestLocals) return; // nothing to do - newScope = scope.$new(); - latestLocals = $state.$current.locals[name]; - - var clone = $transclude(newScope, function(clone) { - renderer.enter(clone, $element, function onUiViewEnter() { - if(currentScope) { - currentScope.$emit('$viewContentAnimationEnded'); - } - - if (angular.isDefined(autoScrollExp) && !autoScrollExp || scope.$eval(autoScrollExp)) { - $uiViewScroll(clone); - } - }); - cleanupLastView(); - }); - - currentEl = clone; - currentScope = newScope; - /** - * @ngdoc event - * @name ui.router.state.directive:ui-view#$viewContentLoaded - * @eventOf ui.router.state.directive:ui-view - * @eventType emits on ui-view directive scope - * @description * - * Fired once the view is **loaded**, *after* the DOM is rendered. - * - * @param {Object} event Event object. - */ - currentScope.$emit('$viewContentLoaded'); - currentScope.$eval(onloadExp); - } - }; - } - }; - - return directive; - } - - $ViewDirectiveFill.$inject = ['$compile', '$controller', '$state', '$interpolate']; - function $ViewDirectiveFill ( $compile, $controller, $state, $interpolate) { - return { - restrict: 'ECA', - priority: -400, - compile: function (tElement) { - var initial = tElement.html(); - return function (scope, $element, attrs) { - var current = $state.$current, - name = getUiViewName(scope, attrs, $element, $interpolate), - locals = current && current.locals[name]; - - if (! locals) { - return; - } - - $element.data('$uiView', { name: name, state: locals.$$state }); - $element.html(locals.$template ? locals.$template : initial); - - var link = $compile($element.contents()); - - if (locals.$$controller) { - locals.$scope = scope; - var controller = $controller(locals.$$controller, locals); - if (locals.$$controllerAs) { - scope[locals.$$controllerAs] = controller; - } - $element.data('$ngControllerController', controller); - $element.children().data('$ngControllerController', controller); - } - - link(scope); - }; - } - }; - } - - /** - * Shared ui-view code for both directives: - * Given scope, element, and its attributes, return the view's name - */ - function getUiViewName(scope, attrs, element, $interpolate) { - var name = $interpolate(attrs.uiView || attrs.name || '')(scope); - var inherited = element.inheritedData('$uiView'); - return name.indexOf('@') >= 0 ? name : (name + '@' + (inherited ? inherited.state.name : '')); - } - - angular.module('ui.router.state').directive('uiView', $ViewDirective); - angular.module('ui.router.state').directive('uiView', $ViewDirectiveFill); - - function parseStateRef(ref, current) { - var preparsed = ref.match(/^\s*({[^}]*})\s*$/), parsed; - if (preparsed) ref = current + '(' + preparsed[1] + ')'; - parsed = ref.replace(/\n/g, " ").match(/^([^(]+?)\s*(\((.*)\))?$/); - if (!parsed || parsed.length !== 4) throw new Error("Invalid state ref '" + ref + "'"); - return { state: parsed[1], paramExpr: parsed[3] || null }; - } - - function stateContext(el) { - var stateData = el.parent().inheritedData('$uiView'); - - if (stateData && stateData.state && stateData.state.name) { - return stateData.state; - } - } - - /** - * @ngdoc directive - * @name ui.router.state.directive:ui-sref - * - * @requires ui.router.state.$state - * @requires $timeout - * - * @restrict A - * - * @description - * A directive that binds a link (`` tag) to a state. If the state has an associated - * URL, the directive will automatically generate & update the `href` attribute via - * the {@link ui.router.state.$state#methods_href $state.href()} method. Clicking - * the link will trigger a state transition with optional parameters. - * - * Also middle-clicking, right-clicking, and ctrl-clicking on the link will be - * handled natively by the browser. - * - * You can also use relative state paths within ui-sref, just like the relative - * paths passed to `$state.go()`. You just need to be aware that the path is relative - * to the state that the link lives in, in other words the state that loaded the - * template containing the link. - * - * You can specify options to pass to {@link ui.router.state.$state#go $state.go()} - * using the `ui-sref-opts` attribute. Options are restricted to `location`, `inherit`, - * and `reload`. - * - * @example - * Here's an example of how you'd use ui-sref and how it would compile. If you have the - * following template: - *
    -   * Home | About | Next page
    -   * 
    -   * 
    -   * 
    - * - * Then the compiled html would be (assuming Html5Mode is off and current state is contacts): - *
    -   * Home | About | Next page
    -   * 
    -   * 
      - *
    • - * Joe - *
    • - *
    • - * Alice - *
    • - *
    • - * Bob - *
    • - *
    - * - * Home - *
    - * - * @param {string} ui-sref 'stateName' can be any valid absolute or relative state - * @param {Object} ui-sref-opts options to pass to {@link ui.router.state.$state#go $state.go()} - */ - $StateRefDirective.$inject = ['$state', '$timeout']; - function $StateRefDirective($state, $timeout) { - var allowedOptions = ['location', 'inherit', 'reload']; - - return { - restrict: 'A', - require: ['?^uiSrefActive', '?^uiSrefActiveEq'], - link: function(scope, element, attrs, uiSrefActive) { - var ref = parseStateRef(attrs.uiSref, $state.current.name); - var params = null, url = null, base = stateContext(element) || $state.$current; - var newHref = null, isAnchor = element.prop("tagName") === "A"; - var isForm = element[0].nodeName === "FORM"; - var attr = isForm ? "action" : "href", nav = true; - - var options = { relative: base, inherit: true }; - var optionsOverride = scope.$eval(attrs.uiSrefOpts) || {}; - - angular.forEach(allowedOptions, function(option) { - if (option in optionsOverride) { - options[option] = optionsOverride[option]; - } - }); - - var update = function(newVal) { - if (newVal) params = angular.copy(newVal); - if (!nav) return; - - newHref = $state.href(ref.state, params, options); - - var activeDirective = uiSrefActive[1] || uiSrefActive[0]; - if (activeDirective) { - activeDirective.$$setStateInfo(ref.state, params); - } - if (newHref === null) { - nav = false; - return false; - } - attrs.$set(attr, newHref); - }; - - if (ref.paramExpr) { - scope.$watch(ref.paramExpr, function(newVal, oldVal) { - if (newVal !== params) update(newVal); - }, true); - params = angular.copy(scope.$eval(ref.paramExpr)); - } - update(); - - if (isForm) return; - - element.bind("click", function(e) { - var button = e.which || e.button; - if ( !(button > 1 || e.ctrlKey || e.metaKey || e.shiftKey || element.attr('target')) ) { - // HACK: This is to allow ng-clicks to be processed before the transition is initiated: - var transition = $timeout(function() { - $state.go(ref.state, params, options); - }); - e.preventDefault(); - - // if the state has no URL, ignore one preventDefault from the directive. - var ignorePreventDefaultCount = isAnchor && !newHref ? 1: 0; - e.preventDefault = function() { - if (ignorePreventDefaultCount-- <= 0) - $timeout.cancel(transition); - }; - } - }); - } - }; - } - - /** - * @ngdoc directive - * @name ui.router.state.directive:ui-sref-active - * - * @requires ui.router.state.$state - * @requires ui.router.state.$stateParams - * @requires $interpolate - * - * @restrict A - * - * @description - * A directive working alongside ui-sref to add classes to an element when the - * related ui-sref directive's state is active, and removing them when it is inactive. - * The primary use-case is to simplify the special appearance of navigation menus - * relying on `ui-sref`, by having the "active" state's menu button appear different, - * distinguishing it from the inactive menu items. - * - * ui-sref-active can live on the same element as ui-sref or on a parent element. The first - * ui-sref-active found at the same level or above the ui-sref will be used. - * - * Will activate when the ui-sref's target state or any child state is active. If you - * need to activate only when the ui-sref target state is active and *not* any of - * it's children, then you will use - * {@link ui.router.state.directive:ui-sref-active-eq ui-sref-active-eq} - * - * @example - * Given the following template: - *
    -   * 
    -   * 
    - * - * - * When the app state is "app.user" (or any children states), and contains the state parameter "user" with value "bilbobaggins", - * the resulting HTML will appear as (note the 'active' class): - *
    -   * 
    -   * 
    - * - * The class name is interpolated **once** during the directives link time (any further changes to the - * interpolated value are ignored). - * - * Multiple classes may be specified in a space-separated format: - *
    -   * 
      - *
    • - * link - *
    • - *
    - *
    - */ - - /** - * @ngdoc directive - * @name ui.router.state.directive:ui-sref-active-eq - * - * @requires ui.router.state.$state - * @requires ui.router.state.$stateParams - * @requires $interpolate - * - * @restrict A - * - * @description - * The same as {@link ui.router.state.directive:ui-sref-active ui-sref-active} but will only activate - * when the exact target state used in the `ui-sref` is active; no child states. - * - */ - $StateRefActiveDirective.$inject = ['$state', '$stateParams', '$interpolate']; - function $StateRefActiveDirective($state, $stateParams, $interpolate) { - return { - restrict: "A", - controller: ['$scope', '$element', '$attrs', function ($scope, $element, $attrs) { - var state, params, activeClass; - - // There probably isn't much point in $observing this - // uiSrefActive and uiSrefActiveEq share the same directive object with some - // slight difference in logic routing - activeClass = $interpolate($attrs.uiSrefActiveEq || $attrs.uiSrefActive || '', false)($scope); - - // Allow uiSref to communicate with uiSrefActive[Equals] - this.$$setStateInfo = function (newState, newParams) { - state = $state.get(newState, stateContext($element)); - params = newParams; - update(); - }; - - $scope.$on('$stateChangeSuccess', update); - - // Update route state - function update() { - if (isMatch()) { - $element.addClass(activeClass); - } else { - $element.removeClass(activeClass); - } - } - - function isMatch() { - if (typeof $attrs.uiSrefActiveEq !== 'undefined') { - return state && $state.is(state.name, params); - } else { - return state && $state.includes(state.name, params); - } - } - }] - }; - } - - angular.module('ui.router.state') - .directive('uiSref', $StateRefDirective) - .directive('uiSrefActive', $StateRefActiveDirective) - .directive('uiSrefActiveEq', $StateRefActiveDirective); - - /** - * @ngdoc filter - * @name ui.router.state.filter:isState - * - * @requires ui.router.state.$state - * - * @description - * Translates to {@link ui.router.state.$state#methods_is $state.is("stateName")}. - */ - $IsStateFilter.$inject = ['$state']; - function $IsStateFilter($state) { - var isFilter = function (state) { - return $state.is(state); - }; - isFilter.$stateful = true; - return isFilter; - } - - /** - * @ngdoc filter - * @name ui.router.state.filter:includedByState - * - * @requires ui.router.state.$state - * - * @description - * Translates to {@link ui.router.state.$state#methods_includes $state.includes('fullOrPartialStateName')}. - */ - $IncludedByStateFilter.$inject = ['$state']; - function $IncludedByStateFilter($state) { - var includesFilter = function (state) { - return $state.includes(state); - }; - includesFilter.$stateful = true; - return includesFilter; - } - - angular.module('ui.router.state') - .filter('isState', $IsStateFilter) - .filter('includedByState', $IncludedByStateFilter); - })(window, window.angular); diff --git a/www/manual_lib/ionic/.bower.json b/www/manual_lib/ionic/.bower.json deleted file mode 100644 index 21b915ce3..000000000 --- a/www/manual_lib/ionic/.bower.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "ionic", - "version": "1.3.3", - "codename": "germany", - "homepage": "https://github.com/driftyco/ionic", - "authors": [ - "Max Lynch ", - "Adam Bradley ", - "Ben Sperry " - ], - "description": "Advanced HTML5 hybrid mobile app development framework.", - "main": [ - "css/ionic.css", - "fonts/*", - "js/ionic.js", - "js/ionic-angular.js" - ], - "keywords": [ - "mobile", - "html5", - "ionic", - "cordova", - "phonegap", - "trigger", - "triggerio", - "angularjs", - "angular" - ], - "license": "MIT", - "private": false, - "dependencies": { - "angular": "1.5.3", - "angular-animate": "1.5.3", - "angular-sanitize": "1.5.3", - "angular-ui-router": "0.2.13" - }, - "_release": "1.3.3", - "_resolution": { - "type": "version", - "tag": "v1.3.3", - "commit": "fc606f21d09bbdc8df6467ec942d51e95afc8036" - }, - "_source": "https://github.com/driftyco/ionic-bower.git", - "_target": "1.3.3", - "_originalSource": "driftyco/ionic-bower" -} \ No newline at end of file diff --git a/www/manual_lib/ionic/README.md b/www/manual_lib/ionic/README.md deleted file mode 100644 index f750bd8c8..000000000 --- a/www/manual_lib/ionic/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# ionic-bower - -Bower repository for [Ionic Framework](http://github.com/driftyco/ionic) - -### Usage - -Include `js/ionic.bundle.js` to get ionic and all of its dependencies. - -Alternatively, include the individual ionic files with the dependencies separately. - -### Versions - -To install the latest stable version, `bower install driftyco/ionic-bower#v1.1.1` - -To install the latest nightly release, `bower install driftyco/ionic-bower#master` diff --git a/www/manual_lib/ionic/bower.json b/www/manual_lib/ionic/bower.json deleted file mode 100644 index 4c71a80df..000000000 --- a/www/manual_lib/ionic/bower.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "name": "ionic", - "version": "1.3.3", - "codename": "germany", - "homepage": "https://github.com/driftyco/ionic", - "authors": [ - "Max Lynch ", - "Adam Bradley ", - "Ben Sperry " - ], - "description": "Advanced HTML5 hybrid mobile app development framework.", - "main": [ - "css/ionic.css", - "fonts/*", - "js/ionic.js", - "js/ionic-angular.js" - ], - "keywords": [ - "mobile", - "html5", - "ionic", - "cordova", - "phonegap", - "trigger", - "triggerio", - "angularjs", - "angular" - ], - "license": "MIT", - "private": false, - "dependencies": { - "angular": "1.5.3", - "angular-animate": "1.5.3", - "angular-sanitize": "1.5.3", - "angular-ui-router": "0.2.13" - } -} diff --git a/www/manual_lib/ionic/css/ionic.css b/www/manual_lib/ionic/css/ionic.css deleted file mode 100644 index f5921de24..000000000 --- a/www/manual_lib/ionic/css/ionic.css +++ /dev/null @@ -1,9813 +0,0 @@ -@charset "UTF-8"; -/*! - * Copyright 2015 Drifty Co. - * http://drifty.com/ - * - * Ionic, v1.3.3 - * A powerful HTML5 mobile app framework. - * http://ionicframework.com/ - * - * By @maxlynch, @benjsperry, @adamdbradley <3 - * - * Licensed under the MIT license. Please see LICENSE for more information. - * - */ -/*! - Ionicons, v2.0.1 - Created by Ben Sperry for the Ionic Framework, http://ionicons.com/ - https://twitter.com/benjsperry https://twitter.com/ionicframework - MIT License: https://github.com/driftyco/ionicons - - Android-style icons originally built by Googleā€™s - Material Design Icons: https://github.com/google/material-design-icons - used under CC BY http://creativecommons.org/licenses/by/4.0/ - Modified icons to fit ioniconā€™s grid from original. -*/ -@font-face { - font-family: "Ionicons"; - src: url("../fonts/ionicons.eot?v=2.0.1"); - src: url("../fonts/ionicons.eot?v=2.0.1#iefix") format("embedded-opentype"), url("../fonts/ionicons.ttf?v=2.0.1") format("truetype"), url("../fonts/ionicons.woff?v=2.0.1") format("woff"), url("../fonts/ionicons.woff") format("woff"), url("../fonts/ionicons.svg?v=2.0.1#Ionicons") format("svg"); - font-weight: normal; - font-style: normal; } - -.ion, .ionicons, -.ion-alert:before, -.ion-alert-circled:before, -.ion-android-add:before, -.ion-android-add-circle:before, -.ion-android-alarm-clock:before, -.ion-android-alert:before, -.ion-android-apps:before, -.ion-android-archive:before, -.ion-android-arrow-back:before, -.ion-android-arrow-down:before, -.ion-android-arrow-dropdown:before, -.ion-android-arrow-dropdown-circle:before, -.ion-android-arrow-dropleft:before, -.ion-android-arrow-dropleft-circle:before, -.ion-android-arrow-dropright:before, -.ion-android-arrow-dropright-circle:before, -.ion-android-arrow-dropup:before, -.ion-android-arrow-dropup-circle:before, -.ion-android-arrow-forward:before, -.ion-android-arrow-up:before, -.ion-android-attach:before, -.ion-android-bar:before, -.ion-android-bicycle:before, -.ion-android-boat:before, -.ion-android-bookmark:before, -.ion-android-bulb:before, -.ion-android-bus:before, -.ion-android-calendar:before, -.ion-android-call:before, -.ion-android-camera:before, -.ion-android-cancel:before, -.ion-android-car:before, -.ion-android-cart:before, -.ion-android-chat:before, -.ion-android-checkbox:before, -.ion-android-checkbox-blank:before, -.ion-android-checkbox-outline:before, -.ion-android-checkbox-outline-blank:before, -.ion-android-checkmark-circle:before, -.ion-android-clipboard:before, -.ion-android-close:before, -.ion-android-cloud:before, -.ion-android-cloud-circle:before, -.ion-android-cloud-done:before, -.ion-android-cloud-outline:before, -.ion-android-color-palette:before, -.ion-android-compass:before, -.ion-android-contact:before, -.ion-android-contacts:before, -.ion-android-contract:before, -.ion-android-create:before, -.ion-android-delete:before, -.ion-android-desktop:before, -.ion-android-document:before, -.ion-android-done:before, -.ion-android-done-all:before, -.ion-android-download:before, -.ion-android-drafts:before, -.ion-android-exit:before, -.ion-android-expand:before, -.ion-android-favorite:before, -.ion-android-favorite-outline:before, -.ion-android-film:before, -.ion-android-folder:before, -.ion-android-folder-open:before, -.ion-android-funnel:before, -.ion-android-globe:before, -.ion-android-hand:before, -.ion-android-hangout:before, -.ion-android-happy:before, -.ion-android-home:before, -.ion-android-image:before, -.ion-android-laptop:before, -.ion-android-list:before, -.ion-android-locate:before, -.ion-android-lock:before, -.ion-android-mail:before, -.ion-android-map:before, -.ion-android-menu:before, -.ion-android-microphone:before, -.ion-android-microphone-off:before, -.ion-android-more-horizontal:before, -.ion-android-more-vertical:before, -.ion-android-navigate:before, -.ion-android-notifications:before, -.ion-android-notifications-none:before, -.ion-android-notifications-off:before, -.ion-android-open:before, -.ion-android-options:before, -.ion-android-people:before, -.ion-android-person:before, -.ion-android-person-add:before, -.ion-android-phone-landscape:before, -.ion-android-phone-portrait:before, -.ion-android-pin:before, -.ion-android-plane:before, -.ion-android-playstore:before, -.ion-android-print:before, -.ion-android-radio-button-off:before, -.ion-android-radio-button-on:before, -.ion-android-refresh:before, -.ion-android-remove:before, -.ion-android-remove-circle:before, -.ion-android-restaurant:before, -.ion-android-sad:before, -.ion-android-search:before, -.ion-android-send:before, -.ion-android-settings:before, -.ion-android-share:before, -.ion-android-share-alt:before, -.ion-android-star:before, -.ion-android-star-half:before, -.ion-android-star-outline:before, -.ion-android-stopwatch:before, -.ion-android-subway:before, -.ion-android-sunny:before, -.ion-android-sync:before, -.ion-android-textsms:before, -.ion-android-time:before, -.ion-android-train:before, -.ion-android-unlock:before, -.ion-android-upload:before, -.ion-android-volume-down:before, -.ion-android-volume-mute:before, -.ion-android-volume-off:before, -.ion-android-volume-up:before, -.ion-android-walk:before, -.ion-android-warning:before, -.ion-android-watch:before, -.ion-android-wifi:before, -.ion-aperture:before, -.ion-archive:before, -.ion-arrow-down-a:before, -.ion-arrow-down-b:before, -.ion-arrow-down-c:before, -.ion-arrow-expand:before, -.ion-arrow-graph-down-left:before, -.ion-arrow-graph-down-right:before, -.ion-arrow-graph-up-left:before, -.ion-arrow-graph-up-right:before, -.ion-arrow-left-a:before, -.ion-arrow-left-b:before, -.ion-arrow-left-c:before, -.ion-arrow-move:before, -.ion-arrow-resize:before, -.ion-arrow-return-left:before, -.ion-arrow-return-right:before, -.ion-arrow-right-a:before, -.ion-arrow-right-b:before, -.ion-arrow-right-c:before, -.ion-arrow-shrink:before, -.ion-arrow-swap:before, -.ion-arrow-up-a:before, -.ion-arrow-up-b:before, -.ion-arrow-up-c:before, -.ion-asterisk:before, -.ion-at:before, -.ion-backspace:before, -.ion-backspace-outline:before, -.ion-bag:before, -.ion-battery-charging:before, -.ion-battery-empty:before, -.ion-battery-full:before, -.ion-battery-half:before, -.ion-battery-low:before, -.ion-beaker:before, -.ion-beer:before, -.ion-bluetooth:before, -.ion-bonfire:before, -.ion-bookmark:before, -.ion-bowtie:before, -.ion-briefcase:before, -.ion-bug:before, -.ion-calculator:before, -.ion-calendar:before, -.ion-camera:before, -.ion-card:before, -.ion-cash:before, -.ion-chatbox:before, -.ion-chatbox-working:before, -.ion-chatboxes:before, -.ion-chatbubble:before, -.ion-chatbubble-working:before, -.ion-chatbubbles:before, -.ion-checkmark:before, -.ion-checkmark-circled:before, -.ion-checkmark-round:before, -.ion-chevron-down:before, -.ion-chevron-left:before, -.ion-chevron-right:before, -.ion-chevron-up:before, -.ion-clipboard:before, -.ion-clock:before, -.ion-close:before, -.ion-close-circled:before, -.ion-close-round:before, -.ion-closed-captioning:before, -.ion-cloud:before, -.ion-code:before, -.ion-code-download:before, -.ion-code-working:before, -.ion-coffee:before, -.ion-compass:before, -.ion-compose:before, -.ion-connection-bars:before, -.ion-contrast:before, -.ion-crop:before, -.ion-cube:before, -.ion-disc:before, -.ion-document:before, -.ion-document-text:before, -.ion-drag:before, -.ion-earth:before, -.ion-easel:before, -.ion-edit:before, -.ion-egg:before, -.ion-eject:before, -.ion-email:before, -.ion-email-unread:before, -.ion-erlenmeyer-flask:before, -.ion-erlenmeyer-flask-bubbles:before, -.ion-eye:before, -.ion-eye-disabled:before, -.ion-female:before, -.ion-filing:before, -.ion-film-marker:before, -.ion-fireball:before, -.ion-flag:before, -.ion-flame:before, -.ion-flash:before, -.ion-flash-off:before, -.ion-folder:before, -.ion-fork:before, -.ion-fork-repo:before, -.ion-forward:before, -.ion-funnel:before, -.ion-gear-a:before, -.ion-gear-b:before, -.ion-grid:before, -.ion-hammer:before, -.ion-happy:before, -.ion-happy-outline:before, -.ion-headphone:before, -.ion-heart:before, -.ion-heart-broken:before, -.ion-help:before, -.ion-help-buoy:before, -.ion-help-circled:before, -.ion-home:before, -.ion-icecream:before, -.ion-image:before, -.ion-images:before, -.ion-information:before, -.ion-information-circled:before, -.ion-ionic:before, -.ion-ios-alarm:before, -.ion-ios-alarm-outline:before, -.ion-ios-albums:before, -.ion-ios-albums-outline:before, -.ion-ios-americanfootball:before, -.ion-ios-americanfootball-outline:before, -.ion-ios-analytics:before, -.ion-ios-analytics-outline:before, -.ion-ios-arrow-back:before, -.ion-ios-arrow-down:before, -.ion-ios-arrow-forward:before, -.ion-ios-arrow-left:before, -.ion-ios-arrow-right:before, -.ion-ios-arrow-thin-down:before, -.ion-ios-arrow-thin-left:before, -.ion-ios-arrow-thin-right:before, -.ion-ios-arrow-thin-up:before, -.ion-ios-arrow-up:before, -.ion-ios-at:before, -.ion-ios-at-outline:before, -.ion-ios-barcode:before, -.ion-ios-barcode-outline:before, -.ion-ios-baseball:before, -.ion-ios-baseball-outline:before, -.ion-ios-basketball:before, -.ion-ios-basketball-outline:before, -.ion-ios-bell:before, -.ion-ios-bell-outline:before, -.ion-ios-body:before, -.ion-ios-body-outline:before, -.ion-ios-bolt:before, -.ion-ios-bolt-outline:before, -.ion-ios-book:before, -.ion-ios-book-outline:before, -.ion-ios-bookmarks:before, -.ion-ios-bookmarks-outline:before, -.ion-ios-box:before, -.ion-ios-box-outline:before, -.ion-ios-briefcase:before, -.ion-ios-briefcase-outline:before, -.ion-ios-browsers:before, -.ion-ios-browsers-outline:before, -.ion-ios-calculator:before, -.ion-ios-calculator-outline:before, -.ion-ios-calendar:before, -.ion-ios-calendar-outline:before, -.ion-ios-camera:before, -.ion-ios-camera-outline:before, -.ion-ios-cart:before, -.ion-ios-cart-outline:before, -.ion-ios-chatboxes:before, -.ion-ios-chatboxes-outline:before, -.ion-ios-chatbubble:before, -.ion-ios-chatbubble-outline:before, -.ion-ios-checkmark:before, -.ion-ios-checkmark-empty:before, -.ion-ios-checkmark-outline:before, -.ion-ios-circle-filled:before, -.ion-ios-circle-outline:before, -.ion-ios-clock:before, -.ion-ios-clock-outline:before, -.ion-ios-close:before, -.ion-ios-close-empty:before, -.ion-ios-close-outline:before, -.ion-ios-cloud:before, -.ion-ios-cloud-download:before, -.ion-ios-cloud-download-outline:before, -.ion-ios-cloud-outline:before, -.ion-ios-cloud-upload:before, -.ion-ios-cloud-upload-outline:before, -.ion-ios-cloudy:before, -.ion-ios-cloudy-night:before, -.ion-ios-cloudy-night-outline:before, -.ion-ios-cloudy-outline:before, -.ion-ios-cog:before, -.ion-ios-cog-outline:before, -.ion-ios-color-filter:before, -.ion-ios-color-filter-outline:before, -.ion-ios-color-wand:before, -.ion-ios-color-wand-outline:before, -.ion-ios-compose:before, -.ion-ios-compose-outline:before, -.ion-ios-contact:before, -.ion-ios-contact-outline:before, -.ion-ios-copy:before, -.ion-ios-copy-outline:before, -.ion-ios-crop:before, -.ion-ios-crop-strong:before, -.ion-ios-download:before, -.ion-ios-download-outline:before, -.ion-ios-drag:before, -.ion-ios-email:before, -.ion-ios-email-outline:before, -.ion-ios-eye:before, -.ion-ios-eye-outline:before, -.ion-ios-fastforward:before, -.ion-ios-fastforward-outline:before, -.ion-ios-filing:before, -.ion-ios-filing-outline:before, -.ion-ios-film:before, -.ion-ios-film-outline:before, -.ion-ios-flag:before, -.ion-ios-flag-outline:before, -.ion-ios-flame:before, -.ion-ios-flame-outline:before, -.ion-ios-flask:before, -.ion-ios-flask-outline:before, -.ion-ios-flower:before, -.ion-ios-flower-outline:before, -.ion-ios-folder:before, -.ion-ios-folder-outline:before, -.ion-ios-football:before, -.ion-ios-football-outline:before, -.ion-ios-game-controller-a:before, -.ion-ios-game-controller-a-outline:before, -.ion-ios-game-controller-b:before, -.ion-ios-game-controller-b-outline:before, -.ion-ios-gear:before, -.ion-ios-gear-outline:before, -.ion-ios-glasses:before, -.ion-ios-glasses-outline:before, -.ion-ios-grid-view:before, -.ion-ios-grid-view-outline:before, -.ion-ios-heart:before, -.ion-ios-heart-outline:before, -.ion-ios-help:before, -.ion-ios-help-empty:before, -.ion-ios-help-outline:before, -.ion-ios-home:before, -.ion-ios-home-outline:before, -.ion-ios-infinite:before, -.ion-ios-infinite-outline:before, -.ion-ios-information:before, -.ion-ios-information-empty:before, -.ion-ios-information-outline:before, -.ion-ios-ionic-outline:before, -.ion-ios-keypad:before, -.ion-ios-keypad-outline:before, -.ion-ios-lightbulb:before, -.ion-ios-lightbulb-outline:before, -.ion-ios-list:before, -.ion-ios-list-outline:before, -.ion-ios-location:before, -.ion-ios-location-outline:before, -.ion-ios-locked:before, -.ion-ios-locked-outline:before, -.ion-ios-loop:before, -.ion-ios-loop-strong:before, -.ion-ios-medical:before, -.ion-ios-medical-outline:before, -.ion-ios-medkit:before, -.ion-ios-medkit-outline:before, -.ion-ios-mic:before, -.ion-ios-mic-off:before, -.ion-ios-mic-outline:before, -.ion-ios-minus:before, -.ion-ios-minus-empty:before, -.ion-ios-minus-outline:before, -.ion-ios-monitor:before, -.ion-ios-monitor-outline:before, -.ion-ios-moon:before, -.ion-ios-moon-outline:before, -.ion-ios-more:before, -.ion-ios-more-outline:before, -.ion-ios-musical-note:before, -.ion-ios-musical-notes:before, -.ion-ios-navigate:before, -.ion-ios-navigate-outline:before, -.ion-ios-nutrition:before, -.ion-ios-nutrition-outline:before, -.ion-ios-paper:before, -.ion-ios-paper-outline:before, -.ion-ios-paperplane:before, -.ion-ios-paperplane-outline:before, -.ion-ios-partlysunny:before, -.ion-ios-partlysunny-outline:before, -.ion-ios-pause:before, -.ion-ios-pause-outline:before, -.ion-ios-paw:before, -.ion-ios-paw-outline:before, -.ion-ios-people:before, -.ion-ios-people-outline:before, -.ion-ios-person:before, -.ion-ios-person-outline:before, -.ion-ios-personadd:before, -.ion-ios-personadd-outline:before, -.ion-ios-photos:before, -.ion-ios-photos-outline:before, -.ion-ios-pie:before, -.ion-ios-pie-outline:before, -.ion-ios-pint:before, -.ion-ios-pint-outline:before, -.ion-ios-play:before, -.ion-ios-play-outline:before, -.ion-ios-plus:before, -.ion-ios-plus-empty:before, -.ion-ios-plus-outline:before, -.ion-ios-pricetag:before, -.ion-ios-pricetag-outline:before, -.ion-ios-pricetags:before, -.ion-ios-pricetags-outline:before, -.ion-ios-printer:before, -.ion-ios-printer-outline:before, -.ion-ios-pulse:before, -.ion-ios-pulse-strong:before, -.ion-ios-rainy:before, -.ion-ios-rainy-outline:before, -.ion-ios-recording:before, -.ion-ios-recording-outline:before, -.ion-ios-redo:before, -.ion-ios-redo-outline:before, -.ion-ios-refresh:before, -.ion-ios-refresh-empty:before, -.ion-ios-refresh-outline:before, -.ion-ios-reload:before, -.ion-ios-reverse-camera:before, -.ion-ios-reverse-camera-outline:before, -.ion-ios-rewind:before, -.ion-ios-rewind-outline:before, -.ion-ios-rose:before, -.ion-ios-rose-outline:before, -.ion-ios-search:before, -.ion-ios-search-strong:before, -.ion-ios-settings:before, -.ion-ios-settings-strong:before, -.ion-ios-shuffle:before, -.ion-ios-shuffle-strong:before, -.ion-ios-skipbackward:before, -.ion-ios-skipbackward-outline:before, -.ion-ios-skipforward:before, -.ion-ios-skipforward-outline:before, -.ion-ios-snowy:before, -.ion-ios-speedometer:before, -.ion-ios-speedometer-outline:before, -.ion-ios-star:before, -.ion-ios-star-half:before, -.ion-ios-star-outline:before, -.ion-ios-stopwatch:before, -.ion-ios-stopwatch-outline:before, -.ion-ios-sunny:before, -.ion-ios-sunny-outline:before, -.ion-ios-telephone:before, -.ion-ios-telephone-outline:before, -.ion-ios-tennisball:before, -.ion-ios-tennisball-outline:before, -.ion-ios-thunderstorm:before, -.ion-ios-thunderstorm-outline:before, -.ion-ios-time:before, -.ion-ios-time-outline:before, -.ion-ios-timer:before, -.ion-ios-timer-outline:before, -.ion-ios-toggle:before, -.ion-ios-toggle-outline:before, -.ion-ios-trash:before, -.ion-ios-trash-outline:before, -.ion-ios-undo:before, -.ion-ios-undo-outline:before, -.ion-ios-unlocked:before, -.ion-ios-unlocked-outline:before, -.ion-ios-upload:before, -.ion-ios-upload-outline:before, -.ion-ios-videocam:before, -.ion-ios-videocam-outline:before, -.ion-ios-volume-high:before, -.ion-ios-volume-low:before, -.ion-ios-wineglass:before, -.ion-ios-wineglass-outline:before, -.ion-ios-world:before, -.ion-ios-world-outline:before, -.ion-ipad:before, -.ion-iphone:before, -.ion-ipod:before, -.ion-jet:before, -.ion-key:before, -.ion-knife:before, -.ion-laptop:before, -.ion-leaf:before, -.ion-levels:before, -.ion-lightbulb:before, -.ion-link:before, -.ion-load-a:before, -.ion-load-b:before, -.ion-load-c:before, -.ion-load-d:before, -.ion-location:before, -.ion-lock-combination:before, -.ion-locked:before, -.ion-log-in:before, -.ion-log-out:before, -.ion-loop:before, -.ion-magnet:before, -.ion-male:before, -.ion-man:before, -.ion-map:before, -.ion-medkit:before, -.ion-merge:before, -.ion-mic-a:before, -.ion-mic-b:before, -.ion-mic-c:before, -.ion-minus:before, -.ion-minus-circled:before, -.ion-minus-round:before, -.ion-model-s:before, -.ion-monitor:before, -.ion-more:before, -.ion-mouse:before, -.ion-music-note:before, -.ion-navicon:before, -.ion-navicon-round:before, -.ion-navigate:before, -.ion-network:before, -.ion-no-smoking:before, -.ion-nuclear:before, -.ion-outlet:before, -.ion-paintbrush:before, -.ion-paintbucket:before, -.ion-paper-airplane:before, -.ion-paperclip:before, -.ion-pause:before, -.ion-person:before, -.ion-person-add:before, -.ion-person-stalker:before, -.ion-pie-graph:before, -.ion-pin:before, -.ion-pinpoint:before, -.ion-pizza:before, -.ion-plane:before, -.ion-planet:before, -.ion-play:before, -.ion-playstation:before, -.ion-plus:before, -.ion-plus-circled:before, -.ion-plus-round:before, -.ion-podium:before, -.ion-pound:before, -.ion-power:before, -.ion-pricetag:before, -.ion-pricetags:before, -.ion-printer:before, -.ion-pull-request:before, -.ion-qr-scanner:before, -.ion-quote:before, -.ion-radio-waves:before, -.ion-record:before, -.ion-refresh:before, -.ion-reply:before, -.ion-reply-all:before, -.ion-ribbon-a:before, -.ion-ribbon-b:before, -.ion-sad:before, -.ion-sad-outline:before, -.ion-scissors:before, -.ion-search:before, -.ion-settings:before, -.ion-share:before, -.ion-shuffle:before, -.ion-skip-backward:before, -.ion-skip-forward:before, -.ion-social-android:before, -.ion-social-android-outline:before, -.ion-social-angular:before, -.ion-social-angular-outline:before, -.ion-social-apple:before, -.ion-social-apple-outline:before, -.ion-social-bitcoin:before, -.ion-social-bitcoin-outline:before, -.ion-social-buffer:before, -.ion-social-buffer-outline:before, -.ion-social-chrome:before, -.ion-social-chrome-outline:before, -.ion-social-codepen:before, -.ion-social-codepen-outline:before, -.ion-social-css3:before, -.ion-social-css3-outline:before, -.ion-social-designernews:before, -.ion-social-designernews-outline:before, -.ion-social-dribbble:before, -.ion-social-dribbble-outline:before, -.ion-social-dropbox:before, -.ion-social-dropbox-outline:before, -.ion-social-euro:before, -.ion-social-euro-outline:before, -.ion-social-facebook:before, -.ion-social-facebook-outline:before, -.ion-social-foursquare:before, -.ion-social-foursquare-outline:before, -.ion-social-freebsd-devil:before, -.ion-social-github:before, -.ion-social-github-outline:before, -.ion-social-google:before, -.ion-social-google-outline:before, -.ion-social-googleplus:before, -.ion-social-googleplus-outline:before, -.ion-social-hackernews:before, -.ion-social-hackernews-outline:before, -.ion-social-html5:before, -.ion-social-html5-outline:before, -.ion-social-instagram:before, -.ion-social-instagram-outline:before, -.ion-social-javascript:before, -.ion-social-javascript-outline:before, -.ion-social-linkedin:before, -.ion-social-linkedin-outline:before, -.ion-social-markdown:before, -.ion-social-nodejs:before, -.ion-social-octocat:before, -.ion-social-pinterest:before, -.ion-social-pinterest-outline:before, -.ion-social-python:before, -.ion-social-reddit:before, -.ion-social-reddit-outline:before, -.ion-social-rss:before, -.ion-social-rss-outline:before, -.ion-social-sass:before, -.ion-social-skype:before, -.ion-social-skype-outline:before, -.ion-social-snapchat:before, -.ion-social-snapchat-outline:before, -.ion-social-tumblr:before, -.ion-social-tumblr-outline:before, -.ion-social-tux:before, -.ion-social-twitch:before, -.ion-social-twitch-outline:before, -.ion-social-twitter:before, -.ion-social-twitter-outline:before, -.ion-social-usd:before, -.ion-social-usd-outline:before, -.ion-social-vimeo:before, -.ion-social-vimeo-outline:before, -.ion-social-whatsapp:before, -.ion-social-whatsapp-outline:before, -.ion-social-windows:before, -.ion-social-windows-outline:before, -.ion-social-wordpress:before, -.ion-social-wordpress-outline:before, -.ion-social-yahoo:before, -.ion-social-yahoo-outline:before, -.ion-social-yen:before, -.ion-social-yen-outline:before, -.ion-social-youtube:before, -.ion-social-youtube-outline:before, -.ion-soup-can:before, -.ion-soup-can-outline:before, -.ion-speakerphone:before, -.ion-speedometer:before, -.ion-spoon:before, -.ion-star:before, -.ion-stats-bars:before, -.ion-steam:before, -.ion-stop:before, -.ion-thermometer:before, -.ion-thumbsdown:before, -.ion-thumbsup:before, -.ion-toggle:before, -.ion-toggle-filled:before, -.ion-transgender:before, -.ion-trash-a:before, -.ion-trash-b:before, -.ion-trophy:before, -.ion-tshirt:before, -.ion-tshirt-outline:before, -.ion-umbrella:before, -.ion-university:before, -.ion-unlocked:before, -.ion-upload:before, -.ion-usb:before, -.ion-videocamera:before, -.ion-volume-high:before, -.ion-volume-low:before, -.ion-volume-medium:before, -.ion-volume-mute:before, -.ion-wand:before, -.ion-waterdrop:before, -.ion-wifi:before, -.ion-wineglass:before, -.ion-woman:before, -.ion-wrench:before, -.ion-xbox:before { - display: inline-block; - font-family: "Ionicons"; - speak: none; - font-style: normal; - font-weight: normal; - font-variant: normal; - text-transform: none; - text-rendering: auto; - line-height: 1; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; } - -.ion-alert:before { - content: "ļ„"; } - -.ion-alert-circled:before { - content: "ļ„€"; } - -.ion-android-add:before { - content: "ļ‹‡"; } - -.ion-android-add-circle:before { - content: "ļ™"; } - -.ion-android-alarm-clock:before { - content: "ļš"; } - -.ion-android-alert:before { - content: "ļ›"; } - -.ion-android-apps:before { - content: "ļœ"; } - -.ion-android-archive:before { - content: "ļ‹‰"; } - -.ion-android-arrow-back:before { - content: "ļ‹Š"; } - -.ion-android-arrow-down:before { - content: "ļ"; } - -.ion-android-arrow-dropdown:before { - content: "ļŸ"; } - -.ion-android-arrow-dropdown-circle:before { - content: "ļž"; } - -.ion-android-arrow-dropleft:before { - content: "ļ”"; } - -.ion-android-arrow-dropleft-circle:before { - content: "ļ "; } - -.ion-android-arrow-dropright:before { - content: "ļ£"; } - -.ion-android-arrow-dropright-circle:before { - content: "ļ¢"; } - -.ion-android-arrow-dropup:before { - content: "ļ„"; } - -.ion-android-arrow-dropup-circle:before { - content: "ļ¤"; } - -.ion-android-arrow-forward:before { - content: "ļŒ"; } - -.ion-android-arrow-up:before { - content: "ļ¦"; } - -.ion-android-attach:before { - content: "ļ§"; } - -.ion-android-bar:before { - content: "ļØ"; } - -.ion-android-bicycle:before { - content: "ļ©"; } - -.ion-android-boat:before { - content: "ļŖ"; } - -.ion-android-bookmark:before { - content: "ļ«"; } - -.ion-android-bulb:before { - content: "ļ¬"; } - -.ion-android-bus:before { - content: "ļ­"; } - -.ion-android-calendar:before { - content: "ļ‹‘"; } - -.ion-android-call:before { - content: "ļ‹’"; } - -.ion-android-camera:before { - content: "ļ‹“"; } - -.ion-android-cancel:before { - content: "ļ®"; } - -.ion-android-car:before { - content: "ļÆ"; } - -.ion-android-cart:before { - content: "ļ°"; } - -.ion-android-chat:before { - content: "ļ‹”"; } - -.ion-android-checkbox:before { - content: "ļ“"; } - -.ion-android-checkbox-blank:before { - content: "ļ±"; } - -.ion-android-checkbox-outline:before { - content: "ļ³"; } - -.ion-android-checkbox-outline-blank:before { - content: "ļ²"; } - -.ion-android-checkmark-circle:before { - content: "ļµ"; } - -.ion-android-clipboard:before { - content: "ļ¶"; } - -.ion-android-close:before { - content: "ļ‹—"; } - -.ion-android-cloud:before { - content: "ļŗ"; } - -.ion-android-cloud-circle:before { - content: "ļ·"; } - -.ion-android-cloud-done:before { - content: "ļø"; } - -.ion-android-cloud-outline:before { - content: "ļ¹"; } - -.ion-android-color-palette:before { - content: "ļ»"; } - -.ion-android-compass:before { - content: "ļ¼"; } - -.ion-android-contact:before { - content: "ļ‹˜"; } - -.ion-android-contacts:before { - content: "ļ‹™"; } - -.ion-android-contract:before { - content: "ļ½"; } - -.ion-android-create:before { - content: "ļ¾"; } - -.ion-android-delete:before { - content: "ļæ"; } - -.ion-android-desktop:before { - content: "ļŽ€"; } - -.ion-android-document:before { - content: "ļŽ"; } - -.ion-android-done:before { - content: "ļŽƒ"; } - -.ion-android-done-all:before { - content: "ļŽ‚"; } - -.ion-android-download:before { - content: "ļ‹"; } - -.ion-android-drafts:before { - content: "ļŽ„"; } - -.ion-android-exit:before { - content: "ļŽ…"; } - -.ion-android-expand:before { - content: "ļŽ†"; } - -.ion-android-favorite:before { - content: "ļŽˆ"; } - -.ion-android-favorite-outline:before { - content: "ļŽ‡"; } - -.ion-android-film:before { - content: "ļŽ‰"; } - -.ion-android-folder:before { - content: "ļ‹ "; } - -.ion-android-folder-open:before { - content: "ļŽŠ"; } - -.ion-android-funnel:before { - content: "ļŽ‹"; } - -.ion-android-globe:before { - content: "ļŽŒ"; } - -.ion-android-hand:before { - content: "ļ‹£"; } - -.ion-android-hangout:before { - content: "ļŽ"; } - -.ion-android-happy:before { - content: "ļŽŽ"; } - -.ion-android-home:before { - content: "ļŽ"; } - -.ion-android-image:before { - content: "ļ‹¤"; } - -.ion-android-laptop:before { - content: "ļŽ"; } - -.ion-android-list:before { - content: "ļŽ‘"; } - -.ion-android-locate:before { - content: "ļ‹©"; } - -.ion-android-lock:before { - content: "ļŽ’"; } - -.ion-android-mail:before { - content: "ļ‹«"; } - -.ion-android-map:before { - content: "ļŽ“"; } - -.ion-android-menu:before { - content: "ļŽ”"; } - -.ion-android-microphone:before { - content: "ļ‹¬"; } - -.ion-android-microphone-off:before { - content: "ļŽ•"; } - -.ion-android-more-horizontal:before { - content: "ļŽ–"; } - -.ion-android-more-vertical:before { - content: "ļŽ—"; } - -.ion-android-navigate:before { - content: "ļŽ˜"; } - -.ion-android-notifications:before { - content: "ļŽ›"; } - -.ion-android-notifications-none:before { - content: "ļŽ™"; } - -.ion-android-notifications-off:before { - content: "ļŽš"; } - -.ion-android-open:before { - content: "ļŽœ"; } - -.ion-android-options:before { - content: "ļŽ"; } - -.ion-android-people:before { - content: "ļŽž"; } - -.ion-android-person:before { - content: "ļŽ "; } - -.ion-android-person-add:before { - content: "ļŽŸ"; } - -.ion-android-phone-landscape:before { - content: "ļŽ”"; } - -.ion-android-phone-portrait:before { - content: "ļŽ¢"; } - -.ion-android-pin:before { - content: "ļŽ£"; } - -.ion-android-plane:before { - content: "ļŽ¤"; } - -.ion-android-playstore:before { - content: "ļ‹°"; } - -.ion-android-print:before { - content: "ļŽ„"; } - -.ion-android-radio-button-off:before { - content: "ļŽ¦"; } - -.ion-android-radio-button-on:before { - content: "ļŽ§"; } - -.ion-android-refresh:before { - content: "ļŽØ"; } - -.ion-android-remove:before { - content: "ļ‹“"; } - -.ion-android-remove-circle:before { - content: "ļŽ©"; } - -.ion-android-restaurant:before { - content: "ļŽŖ"; } - -.ion-android-sad:before { - content: "ļŽ«"; } - -.ion-android-search:before { - content: "ļ‹µ"; } - -.ion-android-send:before { - content: "ļ‹¶"; } - -.ion-android-settings:before { - content: "ļ‹·"; } - -.ion-android-share:before { - content: "ļ‹ø"; } - -.ion-android-share-alt:before { - content: "ļŽ¬"; } - -.ion-android-star:before { - content: "ļ‹¼"; } - -.ion-android-star-half:before { - content: "ļŽ­"; } - -.ion-android-star-outline:before { - content: "ļŽ®"; } - -.ion-android-stopwatch:before { - content: "ļ‹½"; } - -.ion-android-subway:before { - content: "ļŽÆ"; } - -.ion-android-sunny:before { - content: "ļŽ°"; } - -.ion-android-sync:before { - content: "ļŽ±"; } - -.ion-android-textsms:before { - content: "ļŽ²"; } - -.ion-android-time:before { - content: "ļŽ³"; } - -.ion-android-train:before { - content: "ļŽ“"; } - -.ion-android-unlock:before { - content: "ļŽµ"; } - -.ion-android-upload:before { - content: "ļŽ¶"; } - -.ion-android-volume-down:before { - content: "ļŽ·"; } - -.ion-android-volume-mute:before { - content: "ļŽø"; } - -.ion-android-volume-off:before { - content: "ļŽ¹"; } - -.ion-android-volume-up:before { - content: "ļŽŗ"; } - -.ion-android-walk:before { - content: "ļŽ»"; } - -.ion-android-warning:before { - content: "ļŽ¼"; } - -.ion-android-watch:before { - content: "ļŽ½"; } - -.ion-android-wifi:before { - content: "ļŒ…"; } - -.ion-aperture:before { - content: "ļŒ“"; } - -.ion-archive:before { - content: "ļ„‚"; } - -.ion-arrow-down-a:before { - content: "ļ„ƒ"; } - -.ion-arrow-down-b:before { - content: "ļ„„"; } - -.ion-arrow-down-c:before { - content: "ļ„…"; } - -.ion-arrow-expand:before { - content: "ļ‰ž"; } - -.ion-arrow-graph-down-left:before { - content: "ļ‰Ÿ"; } - -.ion-arrow-graph-down-right:before { - content: "ļ‰ "; } - -.ion-arrow-graph-up-left:before { - content: "ļ‰”"; } - -.ion-arrow-graph-up-right:before { - content: "ļ‰¢"; } - -.ion-arrow-left-a:before { - content: "ļ„†"; } - -.ion-arrow-left-b:before { - content: "ļ„‡"; } - -.ion-arrow-left-c:before { - content: "ļ„ˆ"; } - -.ion-arrow-move:before { - content: "ļ‰£"; } - -.ion-arrow-resize:before { - content: "ļ‰¤"; } - -.ion-arrow-return-left:before { - content: "ļ‰„"; } - -.ion-arrow-return-right:before { - content: "ļ‰¦"; } - -.ion-arrow-right-a:before { - content: "ļ„‰"; } - -.ion-arrow-right-b:before { - content: "ļ„Š"; } - -.ion-arrow-right-c:before { - content: "ļ„‹"; } - -.ion-arrow-shrink:before { - content: "ļ‰§"; } - -.ion-arrow-swap:before { - content: "ļ‰Ø"; } - -.ion-arrow-up-a:before { - content: "ļ„Œ"; } - -.ion-arrow-up-b:before { - content: "ļ„"; } - -.ion-arrow-up-c:before { - content: "ļ„Ž"; } - -.ion-asterisk:before { - content: "ļŒ”"; } - -.ion-at:before { - content: "ļ„"; } - -.ion-backspace:before { - content: "ļŽæ"; } - -.ion-backspace-outline:before { - content: "ļŽ¾"; } - -.ion-bag:before { - content: "ļ„"; } - -.ion-battery-charging:before { - content: "ļ„‘"; } - -.ion-battery-empty:before { - content: "ļ„’"; } - -.ion-battery-full:before { - content: "ļ„“"; } - -.ion-battery-half:before { - content: "ļ„”"; } - -.ion-battery-low:before { - content: "ļ„•"; } - -.ion-beaker:before { - content: "ļ‰©"; } - -.ion-beer:before { - content: "ļ‰Ŗ"; } - -.ion-bluetooth:before { - content: "ļ„–"; } - -.ion-bonfire:before { - content: "ļŒ•"; } - -.ion-bookmark:before { - content: "ļ‰«"; } - -.ion-bowtie:before { - content: "ļ€"; } - -.ion-briefcase:before { - content: "ļ‰¬"; } - -.ion-bug:before { - content: "ļŠ¾"; } - -.ion-calculator:before { - content: "ļ‰­"; } - -.ion-calendar:before { - content: "ļ„—"; } - -.ion-camera:before { - content: "ļ„˜"; } - -.ion-card:before { - content: "ļ„™"; } - -.ion-cash:before { - content: "ļŒ–"; } - -.ion-chatbox:before { - content: "ļ„›"; } - -.ion-chatbox-working:before { - content: "ļ„š"; } - -.ion-chatboxes:before { - content: "ļ„œ"; } - -.ion-chatbubble:before { - content: "ļ„ž"; } - -.ion-chatbubble-working:before { - content: "ļ„"; } - -.ion-chatbubbles:before { - content: "ļ„Ÿ"; } - -.ion-checkmark:before { - content: "ļ„¢"; } - -.ion-checkmark-circled:before { - content: "ļ„ "; } - -.ion-checkmark-round:before { - content: "ļ„”"; } - -.ion-chevron-down:before { - content: "ļ„£"; } - -.ion-chevron-left:before { - content: "ļ„¤"; } - -.ion-chevron-right:before { - content: "ļ„„"; } - -.ion-chevron-up:before { - content: "ļ„¦"; } - -.ion-clipboard:before { - content: "ļ„§"; } - -.ion-clock:before { - content: "ļ‰®"; } - -.ion-close:before { - content: "ļ„Ŗ"; } - -.ion-close-circled:before { - content: "ļ„Ø"; } - -.ion-close-round:before { - content: "ļ„©"; } - -.ion-closed-captioning:before { - content: "ļŒ—"; } - -.ion-cloud:before { - content: "ļ„«"; } - -.ion-code:before { - content: "ļ‰±"; } - -.ion-code-download:before { - content: "ļ‰Æ"; } - -.ion-code-working:before { - content: "ļ‰°"; } - -.ion-coffee:before { - content: "ļ‰²"; } - -.ion-compass:before { - content: "ļ‰³"; } - -.ion-compose:before { - content: "ļ„¬"; } - -.ion-connection-bars:before { - content: "ļ‰“"; } - -.ion-contrast:before { - content: "ļ‰µ"; } - -.ion-crop:before { - content: "ļ"; } - -.ion-cube:before { - content: "ļŒ˜"; } - -.ion-disc:before { - content: "ļ„­"; } - -.ion-document:before { - content: "ļ„Æ"; } - -.ion-document-text:before { - content: "ļ„®"; } - -.ion-drag:before { - content: "ļ„°"; } - -.ion-earth:before { - content: "ļ‰¶"; } - -.ion-easel:before { - content: "ļ‚"; } - -.ion-edit:before { - content: "ļŠæ"; } - -.ion-egg:before { - content: "ļ‰·"; } - -.ion-eject:before { - content: "ļ„±"; } - -.ion-email:before { - content: "ļ„²"; } - -.ion-email-unread:before { - content: "ļƒ"; } - -.ion-erlenmeyer-flask:before { - content: "ļ…"; } - -.ion-erlenmeyer-flask-bubbles:before { - content: "ļ„"; } - -.ion-eye:before { - content: "ļ„³"; } - -.ion-eye-disabled:before { - content: "ļŒ†"; } - -.ion-female:before { - content: "ļ‰ø"; } - -.ion-filing:before { - content: "ļ„“"; } - -.ion-film-marker:before { - content: "ļ„µ"; } - -.ion-fireball:before { - content: "ļŒ™"; } - -.ion-flag:before { - content: "ļ‰¹"; } - -.ion-flame:before { - content: "ļŒš"; } - -.ion-flash:before { - content: "ļ„·"; } - -.ion-flash-off:before { - content: "ļ„¶"; } - -.ion-folder:before { - content: "ļ„¹"; } - -.ion-fork:before { - content: "ļ‰ŗ"; } - -.ion-fork-repo:before { - content: "ļ‹€"; } - -.ion-forward:before { - content: "ļ„ŗ"; } - -.ion-funnel:before { - content: "ļŒ›"; } - -.ion-gear-a:before { - content: "ļ„½"; } - -.ion-gear-b:before { - content: "ļ„¾"; } - -.ion-grid:before { - content: "ļ„æ"; } - -.ion-hammer:before { - content: "ļ‰»"; } - -.ion-happy:before { - content: "ļŒœ"; } - -.ion-happy-outline:before { - content: "ļ†"; } - -.ion-headphone:before { - content: "ļ…€"; } - -.ion-heart:before { - content: "ļ…"; } - -.ion-heart-broken:before { - content: "ļŒ"; } - -.ion-help:before { - content: "ļ…ƒ"; } - -.ion-help-buoy:before { - content: "ļ‰¼"; } - -.ion-help-circled:before { - content: "ļ…‚"; } - -.ion-home:before { - content: "ļ…„"; } - -.ion-icecream:before { - content: "ļ‰½"; } - -.ion-image:before { - content: "ļ…‡"; } - -.ion-images:before { - content: "ļ…ˆ"; } - -.ion-information:before { - content: "ļ…Š"; } - -.ion-information-circled:before { - content: "ļ…‰"; } - -.ion-ionic:before { - content: "ļ…‹"; } - -.ion-ios-alarm:before { - content: "ļˆ"; } - -.ion-ios-alarm-outline:before { - content: "ļ‡"; } - -.ion-ios-albums:before { - content: "ļŠ"; } - -.ion-ios-albums-outline:before { - content: "ļ‰"; } - -.ion-ios-americanfootball:before { - content: "ļŒ"; } - -.ion-ios-americanfootball-outline:before { - content: "ļ‹"; } - -.ion-ios-analytics:before { - content: "ļŽ"; } - -.ion-ios-analytics-outline:before { - content: "ļ"; } - -.ion-ios-arrow-back:before { - content: "ļ"; } - -.ion-ios-arrow-down:before { - content: "ļ"; } - -.ion-ios-arrow-forward:before { - content: "ļ‘"; } - -.ion-ios-arrow-left:before { - content: "ļ’"; } - -.ion-ios-arrow-right:before { - content: "ļ“"; } - -.ion-ios-arrow-thin-down:before { - content: "ļ”"; } - -.ion-ios-arrow-thin-left:before { - content: "ļ•"; } - -.ion-ios-arrow-thin-right:before { - content: "ļ–"; } - -.ion-ios-arrow-thin-up:before { - content: "ļ—"; } - -.ion-ios-arrow-up:before { - content: "ļ˜"; } - -.ion-ios-at:before { - content: "ļš"; } - -.ion-ios-at-outline:before { - content: "ļ™"; } - -.ion-ios-barcode:before { - content: "ļœ"; } - -.ion-ios-barcode-outline:before { - content: "ļ›"; } - -.ion-ios-baseball:before { - content: "ļž"; } - -.ion-ios-baseball-outline:before { - content: "ļ"; } - -.ion-ios-basketball:before { - content: "ļ "; } - -.ion-ios-basketball-outline:before { - content: "ļŸ"; } - -.ion-ios-bell:before { - content: "ļ¢"; } - -.ion-ios-bell-outline:before { - content: "ļ”"; } - -.ion-ios-body:before { - content: "ļ¤"; } - -.ion-ios-body-outline:before { - content: "ļ£"; } - -.ion-ios-bolt:before { - content: "ļ¦"; } - -.ion-ios-bolt-outline:before { - content: "ļ„"; } - -.ion-ios-book:before { - content: "ļØ"; } - -.ion-ios-book-outline:before { - content: "ļ§"; } - -.ion-ios-bookmarks:before { - content: "ļŖ"; } - -.ion-ios-bookmarks-outline:before { - content: "ļ©"; } - -.ion-ios-box:before { - content: "ļ¬"; } - -.ion-ios-box-outline:before { - content: "ļ«"; } - -.ion-ios-briefcase:before { - content: "ļ®"; } - -.ion-ios-briefcase-outline:before { - content: "ļ­"; } - -.ion-ios-browsers:before { - content: "ļ°"; } - -.ion-ios-browsers-outline:before { - content: "ļÆ"; } - -.ion-ios-calculator:before { - content: "ļ²"; } - -.ion-ios-calculator-outline:before { - content: "ļ±"; } - -.ion-ios-calendar:before { - content: "ļ“"; } - -.ion-ios-calendar-outline:before { - content: "ļ³"; } - -.ion-ios-camera:before { - content: "ļ¶"; } - -.ion-ios-camera-outline:before { - content: "ļµ"; } - -.ion-ios-cart:before { - content: "ļø"; } - -.ion-ios-cart-outline:before { - content: "ļ·"; } - -.ion-ios-chatboxes:before { - content: "ļŗ"; } - -.ion-ios-chatboxes-outline:before { - content: "ļ¹"; } - -.ion-ios-chatbubble:before { - content: "ļ¼"; } - -.ion-ios-chatbubble-outline:before { - content: "ļ»"; } - -.ion-ios-checkmark:before { - content: "ļæ"; } - -.ion-ios-checkmark-empty:before { - content: "ļ½"; } - -.ion-ios-checkmark-outline:before { - content: "ļ¾"; } - -.ion-ios-circle-filled:before { - content: "ļ€"; } - -.ion-ios-circle-outline:before { - content: "ļ"; } - -.ion-ios-clock:before { - content: "ļƒ"; } - -.ion-ios-clock-outline:before { - content: "ļ‚"; } - -.ion-ios-close:before { - content: "ļ†"; } - -.ion-ios-close-empty:before { - content: "ļ„"; } - -.ion-ios-close-outline:before { - content: "ļ…"; } - -.ion-ios-cloud:before { - content: "ļŒ"; } - -.ion-ios-cloud-download:before { - content: "ļˆ"; } - -.ion-ios-cloud-download-outline:before { - content: "ļ‡"; } - -.ion-ios-cloud-outline:before { - content: "ļ‰"; } - -.ion-ios-cloud-upload:before { - content: "ļ‹"; } - -.ion-ios-cloud-upload-outline:before { - content: "ļŠ"; } - -.ion-ios-cloudy:before { - content: "ļ"; } - -.ion-ios-cloudy-night:before { - content: "ļŽ"; } - -.ion-ios-cloudy-night-outline:before { - content: "ļ"; } - -.ion-ios-cloudy-outline:before { - content: "ļ"; } - -.ion-ios-cog:before { - content: "ļ’"; } - -.ion-ios-cog-outline:before { - content: "ļ‘"; } - -.ion-ios-color-filter:before { - content: "ļ”"; } - -.ion-ios-color-filter-outline:before { - content: "ļ“"; } - -.ion-ios-color-wand:before { - content: "ļ–"; } - -.ion-ios-color-wand-outline:before { - content: "ļ•"; } - -.ion-ios-compose:before { - content: "ļ˜"; } - -.ion-ios-compose-outline:before { - content: "ļ—"; } - -.ion-ios-contact:before { - content: "ļš"; } - -.ion-ios-contact-outline:before { - content: "ļ™"; } - -.ion-ios-copy:before { - content: "ļœ"; } - -.ion-ios-copy-outline:before { - content: "ļ›"; } - -.ion-ios-crop:before { - content: "ļž"; } - -.ion-ios-crop-strong:before { - content: "ļ"; } - -.ion-ios-download:before { - content: "ļ "; } - -.ion-ios-download-outline:before { - content: "ļŸ"; } - -.ion-ios-drag:before { - content: "ļ”"; } - -.ion-ios-email:before { - content: "ļ£"; } - -.ion-ios-email-outline:before { - content: "ļ¢"; } - -.ion-ios-eye:before { - content: "ļ„"; } - -.ion-ios-eye-outline:before { - content: "ļ¤"; } - -.ion-ios-fastforward:before { - content: "ļ§"; } - -.ion-ios-fastforward-outline:before { - content: "ļ¦"; } - -.ion-ios-filing:before { - content: "ļ©"; } - -.ion-ios-filing-outline:before { - content: "ļØ"; } - -.ion-ios-film:before { - content: "ļ«"; } - -.ion-ios-film-outline:before { - content: "ļŖ"; } - -.ion-ios-flag:before { - content: "ļ­"; } - -.ion-ios-flag-outline:before { - content: "ļ¬"; } - -.ion-ios-flame:before { - content: "ļÆ"; } - -.ion-ios-flame-outline:before { - content: "ļ®"; } - -.ion-ios-flask:before { - content: "ļ±"; } - -.ion-ios-flask-outline:before { - content: "ļ°"; } - -.ion-ios-flower:before { - content: "ļ³"; } - -.ion-ios-flower-outline:before { - content: "ļ²"; } - -.ion-ios-folder:before { - content: "ļµ"; } - -.ion-ios-folder-outline:before { - content: "ļ“"; } - -.ion-ios-football:before { - content: "ļ·"; } - -.ion-ios-football-outline:before { - content: "ļ¶"; } - -.ion-ios-game-controller-a:before { - content: "ļ¹"; } - -.ion-ios-game-controller-a-outline:before { - content: "ļø"; } - -.ion-ios-game-controller-b:before { - content: "ļ»"; } - -.ion-ios-game-controller-b-outline:before { - content: "ļŗ"; } - -.ion-ios-gear:before { - content: "ļ½"; } - -.ion-ios-gear-outline:before { - content: "ļ¼"; } - -.ion-ios-glasses:before { - content: "ļæ"; } - -.ion-ios-glasses-outline:before { - content: "ļ¾"; } - -.ion-ios-grid-view:before { - content: "ļ‘"; } - -.ion-ios-grid-view-outline:before { - content: "ļ‘€"; } - -.ion-ios-heart:before { - content: "ļ‘ƒ"; } - -.ion-ios-heart-outline:before { - content: "ļ‘‚"; } - -.ion-ios-help:before { - content: "ļ‘†"; } - -.ion-ios-help-empty:before { - content: "ļ‘„"; } - -.ion-ios-help-outline:before { - content: "ļ‘…"; } - -.ion-ios-home:before { - content: "ļ‘ˆ"; } - -.ion-ios-home-outline:before { - content: "ļ‘‡"; } - -.ion-ios-infinite:before { - content: "ļ‘Š"; } - -.ion-ios-infinite-outline:before { - content: "ļ‘‰"; } - -.ion-ios-information:before { - content: "ļ‘"; } - -.ion-ios-information-empty:before { - content: "ļ‘‹"; } - -.ion-ios-information-outline:before { - content: "ļ‘Œ"; } - -.ion-ios-ionic-outline:before { - content: "ļ‘Ž"; } - -.ion-ios-keypad:before { - content: "ļ‘"; } - -.ion-ios-keypad-outline:before { - content: "ļ‘"; } - -.ion-ios-lightbulb:before { - content: "ļ‘’"; } - -.ion-ios-lightbulb-outline:before { - content: "ļ‘‘"; } - -.ion-ios-list:before { - content: "ļ‘”"; } - -.ion-ios-list-outline:before { - content: "ļ‘“"; } - -.ion-ios-location:before { - content: "ļ‘–"; } - -.ion-ios-location-outline:before { - content: "ļ‘•"; } - -.ion-ios-locked:before { - content: "ļ‘˜"; } - -.ion-ios-locked-outline:before { - content: "ļ‘—"; } - -.ion-ios-loop:before { - content: "ļ‘š"; } - -.ion-ios-loop-strong:before { - content: "ļ‘™"; } - -.ion-ios-medical:before { - content: "ļ‘œ"; } - -.ion-ios-medical-outline:before { - content: "ļ‘›"; } - -.ion-ios-medkit:before { - content: "ļ‘ž"; } - -.ion-ios-medkit-outline:before { - content: "ļ‘"; } - -.ion-ios-mic:before { - content: "ļ‘”"; } - -.ion-ios-mic-off:before { - content: "ļ‘Ÿ"; } - -.ion-ios-mic-outline:before { - content: "ļ‘ "; } - -.ion-ios-minus:before { - content: "ļ‘¤"; } - -.ion-ios-minus-empty:before { - content: "ļ‘¢"; } - -.ion-ios-minus-outline:before { - content: "ļ‘£"; } - -.ion-ios-monitor:before { - content: "ļ‘¦"; } - -.ion-ios-monitor-outline:before { - content: "ļ‘„"; } - -.ion-ios-moon:before { - content: "ļ‘Ø"; } - -.ion-ios-moon-outline:before { - content: "ļ‘§"; } - -.ion-ios-more:before { - content: "ļ‘Ŗ"; } - -.ion-ios-more-outline:before { - content: "ļ‘©"; } - -.ion-ios-musical-note:before { - content: "ļ‘«"; } - -.ion-ios-musical-notes:before { - content: "ļ‘¬"; } - -.ion-ios-navigate:before { - content: "ļ‘®"; } - -.ion-ios-navigate-outline:before { - content: "ļ‘­"; } - -.ion-ios-nutrition:before { - content: "ļ‘°"; } - -.ion-ios-nutrition-outline:before { - content: "ļ‘Æ"; } - -.ion-ios-paper:before { - content: "ļ‘²"; } - -.ion-ios-paper-outline:before { - content: "ļ‘±"; } - -.ion-ios-paperplane:before { - content: "ļ‘“"; } - -.ion-ios-paperplane-outline:before { - content: "ļ‘³"; } - -.ion-ios-partlysunny:before { - content: "ļ‘¶"; } - -.ion-ios-partlysunny-outline:before { - content: "ļ‘µ"; } - -.ion-ios-pause:before { - content: "ļ‘ø"; } - -.ion-ios-pause-outline:before { - content: "ļ‘·"; } - -.ion-ios-paw:before { - content: "ļ‘ŗ"; } - -.ion-ios-paw-outline:before { - content: "ļ‘¹"; } - -.ion-ios-people:before { - content: "ļ‘¼"; } - -.ion-ios-people-outline:before { - content: "ļ‘»"; } - -.ion-ios-person:before { - content: "ļ‘¾"; } - -.ion-ios-person-outline:before { - content: "ļ‘½"; } - -.ion-ios-personadd:before { - content: "ļ’€"; } - -.ion-ios-personadd-outline:before { - content: "ļ‘æ"; } - -.ion-ios-photos:before { - content: "ļ’‚"; } - -.ion-ios-photos-outline:before { - content: "ļ’"; } - -.ion-ios-pie:before { - content: "ļ’„"; } - -.ion-ios-pie-outline:before { - content: "ļ’ƒ"; } - -.ion-ios-pint:before { - content: "ļ’†"; } - -.ion-ios-pint-outline:before { - content: "ļ’…"; } - -.ion-ios-play:before { - content: "ļ’ˆ"; } - -.ion-ios-play-outline:before { - content: "ļ’‡"; } - -.ion-ios-plus:before { - content: "ļ’‹"; } - -.ion-ios-plus-empty:before { - content: "ļ’‰"; } - -.ion-ios-plus-outline:before { - content: "ļ’Š"; } - -.ion-ios-pricetag:before { - content: "ļ’"; } - -.ion-ios-pricetag-outline:before { - content: "ļ’Œ"; } - -.ion-ios-pricetags:before { - content: "ļ’"; } - -.ion-ios-pricetags-outline:before { - content: "ļ’Ž"; } - -.ion-ios-printer:before { - content: "ļ’‘"; } - -.ion-ios-printer-outline:before { - content: "ļ’"; } - -.ion-ios-pulse:before { - content: "ļ’“"; } - -.ion-ios-pulse-strong:before { - content: "ļ’’"; } - -.ion-ios-rainy:before { - content: "ļ’•"; } - -.ion-ios-rainy-outline:before { - content: "ļ’”"; } - -.ion-ios-recording:before { - content: "ļ’—"; } - -.ion-ios-recording-outline:before { - content: "ļ’–"; } - -.ion-ios-redo:before { - content: "ļ’™"; } - -.ion-ios-redo-outline:before { - content: "ļ’˜"; } - -.ion-ios-refresh:before { - content: "ļ’œ"; } - -.ion-ios-refresh-empty:before { - content: "ļ’š"; } - -.ion-ios-refresh-outline:before { - content: "ļ’›"; } - -.ion-ios-reload:before { - content: "ļ’"; } - -.ion-ios-reverse-camera:before { - content: "ļ’Ÿ"; } - -.ion-ios-reverse-camera-outline:before { - content: "ļ’ž"; } - -.ion-ios-rewind:before { - content: "ļ’”"; } - -.ion-ios-rewind-outline:before { - content: "ļ’ "; } - -.ion-ios-rose:before { - content: "ļ’£"; } - -.ion-ios-rose-outline:before { - content: "ļ’¢"; } - -.ion-ios-search:before { - content: "ļ’„"; } - -.ion-ios-search-strong:before { - content: "ļ’¤"; } - -.ion-ios-settings:before { - content: "ļ’§"; } - -.ion-ios-settings-strong:before { - content: "ļ’¦"; } - -.ion-ios-shuffle:before { - content: "ļ’©"; } - -.ion-ios-shuffle-strong:before { - content: "ļ’Ø"; } - -.ion-ios-skipbackward:before { - content: "ļ’«"; } - -.ion-ios-skipbackward-outline:before { - content: "ļ’Ŗ"; } - -.ion-ios-skipforward:before { - content: "ļ’­"; } - -.ion-ios-skipforward-outline:before { - content: "ļ’¬"; } - -.ion-ios-snowy:before { - content: "ļ’®"; } - -.ion-ios-speedometer:before { - content: "ļ’°"; } - -.ion-ios-speedometer-outline:before { - content: "ļ’Æ"; } - -.ion-ios-star:before { - content: "ļ’³"; } - -.ion-ios-star-half:before { - content: "ļ’±"; } - -.ion-ios-star-outline:before { - content: "ļ’²"; } - -.ion-ios-stopwatch:before { - content: "ļ’µ"; } - -.ion-ios-stopwatch-outline:before { - content: "ļ’“"; } - -.ion-ios-sunny:before { - content: "ļ’·"; } - -.ion-ios-sunny-outline:before { - content: "ļ’¶"; } - -.ion-ios-telephone:before { - content: "ļ’¹"; } - -.ion-ios-telephone-outline:before { - content: "ļ’ø"; } - -.ion-ios-tennisball:before { - content: "ļ’»"; } - -.ion-ios-tennisball-outline:before { - content: "ļ’ŗ"; } - -.ion-ios-thunderstorm:before { - content: "ļ’½"; } - -.ion-ios-thunderstorm-outline:before { - content: "ļ’¼"; } - -.ion-ios-time:before { - content: "ļ’æ"; } - -.ion-ios-time-outline:before { - content: "ļ’¾"; } - -.ion-ios-timer:before { - content: "ļ“"; } - -.ion-ios-timer-outline:before { - content: "ļ“€"; } - -.ion-ios-toggle:before { - content: "ļ“ƒ"; } - -.ion-ios-toggle-outline:before { - content: "ļ“‚"; } - -.ion-ios-trash:before { - content: "ļ“…"; } - -.ion-ios-trash-outline:before { - content: "ļ“„"; } - -.ion-ios-undo:before { - content: "ļ“‡"; } - -.ion-ios-undo-outline:before { - content: "ļ“†"; } - -.ion-ios-unlocked:before { - content: "ļ“‰"; } - -.ion-ios-unlocked-outline:before { - content: "ļ“ˆ"; } - -.ion-ios-upload:before { - content: "ļ“‹"; } - -.ion-ios-upload-outline:before { - content: "ļ“Š"; } - -.ion-ios-videocam:before { - content: "ļ“"; } - -.ion-ios-videocam-outline:before { - content: "ļ“Œ"; } - -.ion-ios-volume-high:before { - content: "ļ“Ž"; } - -.ion-ios-volume-low:before { - content: "ļ“"; } - -.ion-ios-wineglass:before { - content: "ļ“‘"; } - -.ion-ios-wineglass-outline:before { - content: "ļ“"; } - -.ion-ios-world:before { - content: "ļ““"; } - -.ion-ios-world-outline:before { - content: "ļ“’"; } - -.ion-ipad:before { - content: "ļ‡¹"; } - -.ion-iphone:before { - content: "ļ‡ŗ"; } - -.ion-ipod:before { - content: "ļ‡»"; } - -.ion-jet:before { - content: "ļŠ•"; } - -.ion-key:before { - content: "ļŠ–"; } - -.ion-knife:before { - content: "ļŠ—"; } - -.ion-laptop:before { - content: "ļ‡¼"; } - -.ion-leaf:before { - content: "ļ‡½"; } - -.ion-levels:before { - content: "ļŠ˜"; } - -.ion-lightbulb:before { - content: "ļŠ™"; } - -.ion-link:before { - content: "ļ‡¾"; } - -.ion-load-a:before { - content: "ļŠš"; } - -.ion-load-b:before { - content: "ļŠ›"; } - -.ion-load-c:before { - content: "ļŠœ"; } - -.ion-load-d:before { - content: "ļŠ"; } - -.ion-location:before { - content: "ļ‡æ"; } - -.ion-lock-combination:before { - content: "ļ“”"; } - -.ion-locked:before { - content: "ļˆ€"; } - -.ion-log-in:before { - content: "ļŠž"; } - -.ion-log-out:before { - content: "ļŠŸ"; } - -.ion-loop:before { - content: "ļˆ"; } - -.ion-magnet:before { - content: "ļŠ "; } - -.ion-male:before { - content: "ļŠ”"; } - -.ion-man:before { - content: "ļˆ‚"; } - -.ion-map:before { - content: "ļˆƒ"; } - -.ion-medkit:before { - content: "ļŠ¢"; } - -.ion-merge:before { - content: "ļŒæ"; } - -.ion-mic-a:before { - content: "ļˆ„"; } - -.ion-mic-b:before { - content: "ļˆ…"; } - -.ion-mic-c:before { - content: "ļˆ†"; } - -.ion-minus:before { - content: "ļˆ‰"; } - -.ion-minus-circled:before { - content: "ļˆ‡"; } - -.ion-minus-round:before { - content: "ļˆˆ"; } - -.ion-model-s:before { - content: "ļ‹"; } - -.ion-monitor:before { - content: "ļˆŠ"; } - -.ion-more:before { - content: "ļˆ‹"; } - -.ion-mouse:before { - content: "ļ€"; } - -.ion-music-note:before { - content: "ļˆŒ"; } - -.ion-navicon:before { - content: "ļˆŽ"; } - -.ion-navicon-round:before { - content: "ļˆ"; } - -.ion-navigate:before { - content: "ļŠ£"; } - -.ion-network:before { - content: "ļ"; } - -.ion-no-smoking:before { - content: "ļ‹‚"; } - -.ion-nuclear:before { - content: "ļŠ¤"; } - -.ion-outlet:before { - content: "ļ‚"; } - -.ion-paintbrush:before { - content: "ļ“•"; } - -.ion-paintbucket:before { - content: "ļ“–"; } - -.ion-paper-airplane:before { - content: "ļ‹ƒ"; } - -.ion-paperclip:before { - content: "ļˆ"; } - -.ion-pause:before { - content: "ļˆ"; } - -.ion-person:before { - content: "ļˆ“"; } - -.ion-person-add:before { - content: "ļˆ‘"; } - -.ion-person-stalker:before { - content: "ļˆ’"; } - -.ion-pie-graph:before { - content: "ļŠ„"; } - -.ion-pin:before { - content: "ļŠ¦"; } - -.ion-pinpoint:before { - content: "ļŠ§"; } - -.ion-pizza:before { - content: "ļŠØ"; } - -.ion-plane:before { - content: "ļˆ”"; } - -.ion-planet:before { - content: "ļƒ"; } - -.ion-play:before { - content: "ļˆ•"; } - -.ion-playstation:before { - content: "ļŒŠ"; } - -.ion-plus:before { - content: "ļˆ˜"; } - -.ion-plus-circled:before { - content: "ļˆ–"; } - -.ion-plus-round:before { - content: "ļˆ—"; } - -.ion-podium:before { - content: "ļ„"; } - -.ion-pound:before { - content: "ļˆ™"; } - -.ion-power:before { - content: "ļŠ©"; } - -.ion-pricetag:before { - content: "ļŠŖ"; } - -.ion-pricetags:before { - content: "ļŠ«"; } - -.ion-printer:before { - content: "ļˆš"; } - -.ion-pull-request:before { - content: "ļ…"; } - -.ion-qr-scanner:before { - content: "ļ†"; } - -.ion-quote:before { - content: "ļ‡"; } - -.ion-radio-waves:before { - content: "ļŠ¬"; } - -.ion-record:before { - content: "ļˆ›"; } - -.ion-refresh:before { - content: "ļˆœ"; } - -.ion-reply:before { - content: "ļˆž"; } - -.ion-reply-all:before { - content: "ļˆ"; } - -.ion-ribbon-a:before { - content: "ļˆ"; } - -.ion-ribbon-b:before { - content: "ļ‰"; } - -.ion-sad:before { - content: "ļŠ"; } - -.ion-sad-outline:before { - content: "ļ“—"; } - -.ion-scissors:before { - content: "ļ‹"; } - -.ion-search:before { - content: "ļˆŸ"; } - -.ion-settings:before { - content: "ļŠ­"; } - -.ion-share:before { - content: "ļˆ "; } - -.ion-shuffle:before { - content: "ļˆ”"; } - -.ion-skip-backward:before { - content: "ļˆ¢"; } - -.ion-skip-forward:before { - content: "ļˆ£"; } - -.ion-social-android:before { - content: "ļˆ„"; } - -.ion-social-android-outline:before { - content: "ļˆ¤"; } - -.ion-social-angular:before { - content: "ļ“™"; } - -.ion-social-angular-outline:before { - content: "ļ“˜"; } - -.ion-social-apple:before { - content: "ļˆ§"; } - -.ion-social-apple-outline:before { - content: "ļˆ¦"; } - -.ion-social-bitcoin:before { - content: "ļŠÆ"; } - -.ion-social-bitcoin-outline:before { - content: "ļŠ®"; } - -.ion-social-buffer:before { - content: "ļˆ©"; } - -.ion-social-buffer-outline:before { - content: "ļˆØ"; } - -.ion-social-chrome:before { - content: "ļ“›"; } - -.ion-social-chrome-outline:before { - content: "ļ“š"; } - -.ion-social-codepen:before { - content: "ļ“"; } - -.ion-social-codepen-outline:before { - content: "ļ“œ"; } - -.ion-social-css3:before { - content: "ļ“Ÿ"; } - -.ion-social-css3-outline:before { - content: "ļ“ž"; } - -.ion-social-designernews:before { - content: "ļˆ«"; } - -.ion-social-designernews-outline:before { - content: "ļˆŖ"; } - -.ion-social-dribbble:before { - content: "ļˆ­"; } - -.ion-social-dribbble-outline:before { - content: "ļˆ¬"; } - -.ion-social-dropbox:before { - content: "ļˆÆ"; } - -.ion-social-dropbox-outline:before { - content: "ļˆ®"; } - -.ion-social-euro:before { - content: "ļ“”"; } - -.ion-social-euro-outline:before { - content: "ļ“ "; } - -.ion-social-facebook:before { - content: "ļˆ±"; } - -.ion-social-facebook-outline:before { - content: "ļˆ°"; } - -.ion-social-foursquare:before { - content: "ļ"; } - -.ion-social-foursquare-outline:before { - content: "ļŒ"; } - -.ion-social-freebsd-devil:before { - content: "ļ‹„"; } - -.ion-social-github:before { - content: "ļˆ³"; } - -.ion-social-github-outline:before { - content: "ļˆ²"; } - -.ion-social-google:before { - content: "ļ"; } - -.ion-social-google-outline:before { - content: "ļŽ"; } - -.ion-social-googleplus:before { - content: "ļˆµ"; } - -.ion-social-googleplus-outline:before { - content: "ļˆ“"; } - -.ion-social-hackernews:before { - content: "ļˆ·"; } - -.ion-social-hackernews-outline:before { - content: "ļˆ¶"; } - -.ion-social-html5:before { - content: "ļ“£"; } - -.ion-social-html5-outline:before { - content: "ļ“¢"; } - -.ion-social-instagram:before { - content: "ļ‘"; } - -.ion-social-instagram-outline:before { - content: "ļ"; } - -.ion-social-javascript:before { - content: "ļ“„"; } - -.ion-social-javascript-outline:before { - content: "ļ“¤"; } - -.ion-social-linkedin:before { - content: "ļˆ¹"; } - -.ion-social-linkedin-outline:before { - content: "ļˆø"; } - -.ion-social-markdown:before { - content: "ļ“¦"; } - -.ion-social-nodejs:before { - content: "ļ“§"; } - -.ion-social-octocat:before { - content: "ļ“Ø"; } - -.ion-social-pinterest:before { - content: "ļŠ±"; } - -.ion-social-pinterest-outline:before { - content: "ļŠ°"; } - -.ion-social-python:before { - content: "ļ“©"; } - -.ion-social-reddit:before { - content: "ļˆ»"; } - -.ion-social-reddit-outline:before { - content: "ļˆŗ"; } - -.ion-social-rss:before { - content: "ļˆ½"; } - -.ion-social-rss-outline:before { - content: "ļˆ¼"; } - -.ion-social-sass:before { - content: "ļ“Ŗ"; } - -.ion-social-skype:before { - content: "ļˆæ"; } - -.ion-social-skype-outline:before { - content: "ļˆ¾"; } - -.ion-social-snapchat:before { - content: "ļ“¬"; } - -.ion-social-snapchat-outline:before { - content: "ļ“«"; } - -.ion-social-tumblr:before { - content: "ļ‰"; } - -.ion-social-tumblr-outline:before { - content: "ļ‰€"; } - -.ion-social-tux:before { - content: "ļ‹…"; } - -.ion-social-twitch:before { - content: "ļ“®"; } - -.ion-social-twitch-outline:before { - content: "ļ“­"; } - -.ion-social-twitter:before { - content: "ļ‰ƒ"; } - -.ion-social-twitter-outline:before { - content: "ļ‰‚"; } - -.ion-social-usd:before { - content: "ļ“"; } - -.ion-social-usd-outline:before { - content: "ļ’"; } - -.ion-social-vimeo:before { - content: "ļ‰…"; } - -.ion-social-vimeo-outline:before { - content: "ļ‰„"; } - -.ion-social-whatsapp:before { - content: "ļ“°"; } - -.ion-social-whatsapp-outline:before { - content: "ļ“Æ"; } - -.ion-social-windows:before { - content: "ļ‰‡"; } - -.ion-social-windows-outline:before { - content: "ļ‰†"; } - -.ion-social-wordpress:before { - content: "ļ‰‰"; } - -.ion-social-wordpress-outline:before { - content: "ļ‰ˆ"; } - -.ion-social-yahoo:before { - content: "ļ‰‹"; } - -.ion-social-yahoo-outline:before { - content: "ļ‰Š"; } - -.ion-social-yen:before { - content: "ļ“²"; } - -.ion-social-yen-outline:before { - content: "ļ“±"; } - -.ion-social-youtube:before { - content: "ļ‰"; } - -.ion-social-youtube-outline:before { - content: "ļ‰Œ"; } - -.ion-soup-can:before { - content: "ļ““"; } - -.ion-soup-can-outline:before { - content: "ļ“³"; } - -.ion-speakerphone:before { - content: "ļŠ²"; } - -.ion-speedometer:before { - content: "ļŠ³"; } - -.ion-spoon:before { - content: "ļŠ“"; } - -.ion-star:before { - content: "ļ‰Ž"; } - -.ion-stats-bars:before { - content: "ļŠµ"; } - -.ion-steam:before { - content: "ļŒ‹"; } - -.ion-stop:before { - content: "ļ‰"; } - -.ion-thermometer:before { - content: "ļŠ¶"; } - -.ion-thumbsdown:before { - content: "ļ‰"; } - -.ion-thumbsup:before { - content: "ļ‰‘"; } - -.ion-toggle:before { - content: "ļ•"; } - -.ion-toggle-filled:before { - content: "ļ”"; } - -.ion-transgender:before { - content: "ļ“µ"; } - -.ion-trash-a:before { - content: "ļ‰’"; } - -.ion-trash-b:before { - content: "ļ‰“"; } - -.ion-trophy:before { - content: "ļ–"; } - -.ion-tshirt:before { - content: "ļ“·"; } - -.ion-tshirt-outline:before { - content: "ļ“¶"; } - -.ion-umbrella:before { - content: "ļŠ·"; } - -.ion-university:before { - content: "ļ—"; } - -.ion-unlocked:before { - content: "ļ‰”"; } - -.ion-upload:before { - content: "ļ‰•"; } - -.ion-usb:before { - content: "ļŠø"; } - -.ion-videocamera:before { - content: "ļ‰–"; } - -.ion-volume-high:before { - content: "ļ‰—"; } - -.ion-volume-low:before { - content: "ļ‰˜"; } - -.ion-volume-medium:before { - content: "ļ‰™"; } - -.ion-volume-mute:before { - content: "ļ‰š"; } - -.ion-wand:before { - content: "ļ˜"; } - -.ion-waterdrop:before { - content: "ļ‰›"; } - -.ion-wifi:before { - content: "ļ‰œ"; } - -.ion-wineglass:before { - content: "ļŠ¹"; } - -.ion-woman:before { - content: "ļ‰"; } - -.ion-wrench:before { - content: "ļŠŗ"; } - -.ion-xbox:before { - content: "ļŒŒ"; } - -/** - * Resets - * -------------------------------------------------- - * Adapted from normalize.css and some reset.css. We don't care even one - * bit about old IE, so we don't need any hacks for that in here. - * - * There are probably other things we could remove here, as well. - * - * normalize.css v2.1.2 | MIT License | git.io/normalize - - * Eric Meyer's Reset CSS v2.0 (http://meyerweb.com/eric/tools/css/reset/) - * http://cssreset.com - */ -html, body, div, span, applet, object, iframe, -h1, h2, h3, h4, h5, h6, p, blockquote, pre, -a, abbr, acronym, address, big, cite, code, -del, dfn, em, img, ins, kbd, q, s, samp, -small, strike, strong, sub, sup, tt, var, -b, i, u, center, -dl, dt, dd, ol, ul, li, -fieldset, form, label, legend, -table, caption, tbody, tfoot, thead, tr, th, td, -article, aside, canvas, details, embed, fieldset, -figure, figcaption, footer, header, hgroup, -menu, nav, output, ruby, section, summary, -time, mark, audio, video { - margin: 0; - padding: 0; - border: 0; - vertical-align: baseline; - font: inherit; - font-size: 100%; } - -ol, ul { - list-style: none; } - -blockquote, q { - quotes: none; } - -blockquote:before, blockquote:after, -q:before, q:after { - content: ''; - content: none; } - -/** - * Prevent modern browsers from displaying `audio` without controls. - * Remove excess height in iOS 5 devices. - */ -audio:not([controls]) { - display: none; - height: 0; } - -/** - * Hide the `template` element in IE, Safari, and Firefox < 22. - */ -[hidden], -template { - display: none; } - -script { - display: none !important; } - -/* ========================================================================== - Base - ========================================================================== */ -/** - * 1. Set default font family to sans-serif. - * 2. Prevent iOS text size adjust after orientation change, without disabling - * user zoom. - */ -html { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - font-family: sans-serif; - /* 1 */ - -webkit-text-size-adjust: 100%; - -ms-text-size-adjust: 100%; - /* 2 */ - -webkit-text-size-adjust: 100%; - /* 2 */ } - -/** - * Remove default margin. - */ -body { - margin: 0; - line-height: 1; } - -/** - * Remove default outlines. - */ -a, -button, -:focus, -a:focus, -button:focus, -a:active, -a:hover { - outline: 0; } - -/* * - * Remove tap highlight color - */ -a { - -webkit-user-drag: none; - -webkit-tap-highlight-color: transparent; - -webkit-tap-highlight-color: transparent; } - a[href]:hover { - cursor: pointer; } - -/* ========================================================================== - Typography - ========================================================================== */ -/** - * Address style set to `bolder` in Firefox 4+, Safari 5, and Chrome. - */ -b, -strong { - font-weight: bold; } - -/** - * Address styling not present in Safari 5 and Chrome. - */ -dfn { - font-style: italic; } - -/** - * Address differences between Firefox and other browsers. - */ -hr { - -moz-box-sizing: content-box; - box-sizing: content-box; - height: 0; } - -/** - * Correct font family set oddly in Safari 5 and Chrome. - */ -code, -kbd, -pre, -samp { - font-size: 1em; - font-family: monospace, serif; } - -/** - * Improve readability of pre-formatted text in all browsers. - */ -pre { - white-space: pre-wrap; } - -/** - * Set consistent quote types. - */ -q { - quotes: "\201C" "\201D" "\2018" "\2019"; } - -/** - * Address inconsistent and variable font size in all browsers. - */ -small { - font-size: 80%; } - -/** - * Prevent `sub` and `sup` affecting `line-height` in all browsers. - */ -sub, -sup { - position: relative; - vertical-align: baseline; - font-size: 75%; - line-height: 0; } - -sup { - top: -0.5em; } - -sub { - bottom: -0.25em; } - -/** - * Define consistent border, margin, and padding. - */ -fieldset { - margin: 0 2px; - padding: 0.35em 0.625em 0.75em; - border: 1px solid #c0c0c0; } - -/** - * 1. Correct `color` not being inherited in IE 8/9. - * 2. Remove padding so people aren't caught out if they zero out fieldsets. - */ -legend { - padding: 0; - /* 2 */ - border: 0; - /* 1 */ } - -/** - * 1. Correct font family not being inherited in all browsers. - * 2. Correct font size not being inherited in all browsers. - * 3. Address margins set differently in Firefox 4+, Safari 5, and Chrome. - * 4. Remove any default :focus styles - * 5. Make sure webkit font smoothing is being inherited - * 6. Remove default gradient in Android Firefox / FirefoxOS - */ -button, -input, -select, -textarea { - margin: 0; - /* 3 */ - font-size: 100%; - /* 2 */ - font-family: inherit; - /* 1 */ - outline-offset: 0; - /* 4 */ - outline-style: none; - /* 4 */ - outline-width: 0; - /* 4 */ - -webkit-font-smoothing: inherit; - /* 5 */ - background-image: none; - /* 6 */ } - -/** - * Address Firefox 4+ setting `line-height` on `input` using `importnt` in - * the UA stylesheet. - */ -button, -input { - line-height: normal; } - -/** - * Address inconsistent `text-transform` inheritance for `button` and `select`. - * All other form control elements do not inherit `text-transform` values. - * Correct `button` style inheritance in Chrome, Safari 5+, and IE 8+. - * Correct `select` style inheritance in Firefox 4+ and Opera. - */ -button, -select { - text-transform: none; } - -/** - * 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio` - * and `video` controls. - * 2. Correct inability to style clickable `input` types in iOS. - * 3. Improve usability and consistency of cursor style between image-type - * `input` and others. - */ -button, -html input[type="button"], -input[type="reset"], -input[type="submit"] { - cursor: pointer; - /* 3 */ - -webkit-appearance: button; - /* 2 */ } - -/** - * Re-set default cursor for disabled elements. - */ -button[disabled], -html input[disabled] { - cursor: default; } - -/** - * 1. Address `appearance` set to `searchfield` in Safari 5 and Chrome. - * 2. Address `box-sizing` set to `border-box` in Safari 5 and Chrome - * (include `-moz` to future-proof). - */ -input[type="search"] { - -webkit-box-sizing: content-box; - /* 2 */ - -moz-box-sizing: content-box; - box-sizing: content-box; - -webkit-appearance: textfield; - /* 1 */ } - -/** - * Remove inner padding and search cancel button in Safari 5 and Chrome - * on OS X. - */ -input[type="search"]::-webkit-search-cancel-button, -input[type="search"]::-webkit-search-decoration { - -webkit-appearance: none; } - -/** - * Remove inner padding and border in Firefox 4+. - */ -button::-moz-focus-inner, -input::-moz-focus-inner { - padding: 0; - border: 0; } - -/** - * 1. Remove default vertical scrollbar in IE 8/9. - * 2. Improve readability and alignment in all browsers. - */ -textarea { - overflow: auto; - /* 1 */ - vertical-align: top; - /* 2 */ } - -img { - -webkit-user-drag: none; } - -/* ========================================================================== - Tables - ========================================================================== */ -/** - * Remove most spacing between table cells. - */ -table { - border-spacing: 0; - border-collapse: collapse; } - -/** - * Scaffolding - * -------------------------------------------------- - */ -*, -*:before, -*:after { - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; } - -html { - overflow: hidden; - -ms-touch-action: pan-y; - touch-action: pan-y; } - -body, -.ionic-body { - -webkit-touch-callout: none; - -webkit-font-smoothing: antialiased; - font-smoothing: antialiased; - -webkit-text-size-adjust: none; - -moz-text-size-adjust: none; - text-size-adjust: none; - -webkit-tap-highlight-color: transparent; - -webkit-tap-highlight-color: transparent; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - top: 0; - right: 0; - bottom: 0; - left: 0; - overflow: hidden; - margin: 0; - padding: 0; - color: #000; - word-wrap: break-word; - font-size: 14px; - font-family: -apple-system; - font-family: "-apple-system", "Helvetica Neue", "Roboto", "Segoe UI", sans-serif; - line-height: 20px; - text-rendering: optimizeLegibility; - -webkit-backface-visibility: hidden; - -webkit-user-drag: none; - -ms-content-zooming: none; } - -body.grade-b, -body.grade-c { - text-rendering: auto; } - -.content { - position: relative; } - -.scroll-content { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - overflow: hidden; - margin-top: -1px; - padding-top: 1px; - margin-bottom: -1px; - width: auto; - height: auto; } - -.menu .scroll-content.scroll-content-false { - z-index: 11; } - -.scroll-view { - position: relative; - display: block; - overflow: hidden; - margin-top: -1px; } - .scroll-view.overflow-scroll { - position: relative; } - .scroll-view.scroll-x { - overflow-x: scroll; - overflow-y: hidden; } - .scroll-view.scroll-y { - overflow-x: hidden; - overflow-y: scroll; } - .scroll-view.scroll-xy { - overflow-x: scroll; - overflow-y: scroll; } - -/** - * Scroll is the scroll view component available for complex and custom - * scroll view functionality. - */ -.scroll { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - -webkit-touch-callout: none; - -webkit-text-size-adjust: none; - -moz-text-size-adjust: none; - text-size-adjust: none; - -webkit-transform-origin: left top; - transform-origin: left top; } - -/** - * Set ms-viewport to prevent MS "page squish" and allow fluid scrolling - * https://msdn.microsoft.com/en-us/library/ie/hh869615(v=vs.85).aspx - */ -@-ms-viewport { - width: device-width; } - -.scroll-bar { - position: absolute; - z-index: 9999; } - -.ng-animate .scroll-bar { - visibility: hidden; } - -.scroll-bar-h { - right: 2px; - bottom: 3px; - left: 2px; - height: 3px; } - .scroll-bar-h .scroll-bar-indicator { - height: 100%; } - -.scroll-bar-v { - top: 2px; - right: 3px; - bottom: 2px; - width: 3px; } - .scroll-bar-v .scroll-bar-indicator { - width: 100%; } - -.scroll-bar-indicator { - position: absolute; - border-radius: 4px; - background: rgba(0, 0, 0, 0.3); - opacity: 1; - -webkit-transition: opacity 0.3s linear; - transition: opacity 0.3s linear; } - .scroll-bar-indicator.scroll-bar-fade-out { - opacity: 0; } - -.platform-android .scroll-bar-indicator { - border-radius: 0; } - -.grade-b .scroll-bar-indicator, -.grade-c .scroll-bar-indicator { - background: #aaa; } - .grade-b .scroll-bar-indicator.scroll-bar-fade-out, - .grade-c .scroll-bar-indicator.scroll-bar-fade-out { - -webkit-transition: none; - transition: none; } - -ion-infinite-scroll { - height: 60px; - width: 100%; - display: block; - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-direction: normal; - -webkit-box-orient: horizontal; - -webkit-flex-direction: row; - -moz-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - -webkit-box-pack: center; - -ms-flex-pack: center; - -webkit-justify-content: center; - -moz-justify-content: center; - justify-content: center; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; } - ion-infinite-scroll .icon { - color: #666666; - font-size: 30px; - color: #666666; } - ion-infinite-scroll:not(.active) .spinner, - ion-infinite-scroll:not(.active) .icon:before { - display: none; } - -.overflow-scroll { - overflow-x: hidden; - overflow-y: scroll; - -webkit-overflow-scrolling: touch; - -ms-overflow-style: -ms-autohiding-scrollbar; - top: 0; - right: 0; - bottom: 0; - left: 0; - position: absolute; } - .overflow-scroll.pane { - overflow-x: hidden; - overflow-y: scroll; } - .overflow-scroll .scroll { - position: static; - height: 100%; - -webkit-transform: translate3d(0, 0, 0); } - -/* If you change these, change platform.scss as well */ -.has-header { - top: 44px; } - -.no-header { - top: 0; } - -.has-subheader { - top: 88px; } - -.has-tabs-top { - top: 93px; } - -.has-header.has-subheader.has-tabs-top { - top: 137px; } - -.has-footer { - bottom: 44px; } - -.has-subfooter { - bottom: 88px; } - -.has-tabs, -.bar-footer.has-tabs { - bottom: 49px; } - .has-tabs.pane, - .bar-footer.has-tabs.pane { - bottom: 49px; - height: auto; } - -.bar-subfooter.has-tabs { - bottom: 93px; } - -.has-footer.has-tabs { - bottom: 93px; } - -.pane { - -webkit-transform: translate3d(0, 0, 0); - transform: translate3d(0, 0, 0); - -webkit-transition-duration: 0; - transition-duration: 0; - z-index: 1; } - -.view { - z-index: 1; } - -.pane, -.view { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - width: 100%; - height: 100%; - background-color: #fff; - overflow: hidden; } - -.view-container { - position: absolute; - display: block; - width: 100%; - height: 100%; } - -/** - * Typography - * -------------------------------------------------- - */ -p { - margin: 0 0 10px; } - -small { - font-size: 85%; } - -cite { - font-style: normal; } - -.text-left { - text-align: left; } - -.text-right { - text-align: right; } - -.text-center { - text-align: center; } - -h1, h2, h3, h4, h5, h6, -.h1, .h2, .h3, .h4, .h5, .h6 { - color: #000; - font-weight: 500; - font-family: "-apple-system", "Helvetica Neue", "Roboto", "Segoe UI", sans-serif; - line-height: 1.2; } - h1 small, h2 small, h3 small, h4 small, h5 small, h6 small, - .h1 small, .h2 small, .h3 small, .h4 small, .h5 small, .h6 small { - font-weight: normal; - line-height: 1; } - -h1, .h1, -h2, .h2, -h3, .h3 { - margin-top: 20px; - margin-bottom: 10px; } - h1:first-child, .h1:first-child, - h2:first-child, .h2:first-child, - h3:first-child, .h3:first-child { - margin-top: 0; } - h1 + h1, h1 + .h1, - h1 + h2, h1 + .h2, - h1 + h3, h1 + .h3, .h1 + h1, .h1 + .h1, - .h1 + h2, .h1 + .h2, - .h1 + h3, .h1 + .h3, - h2 + h1, - h2 + .h1, - h2 + h2, - h2 + .h2, - h2 + h3, - h2 + .h3, .h2 + h1, .h2 + .h1, - .h2 + h2, .h2 + .h2, - .h2 + h3, .h2 + .h3, - h3 + h1, - h3 + .h1, - h3 + h2, - h3 + .h2, - h3 + h3, - h3 + .h3, .h3 + h1, .h3 + .h1, - .h3 + h2, .h3 + .h2, - .h3 + h3, .h3 + .h3 { - margin-top: 10px; } - -h4, .h4, -h5, .h5, -h6, .h6 { - margin-top: 10px; - margin-bottom: 10px; } - -h1, .h1 { - font-size: 36px; } - -h2, .h2 { - font-size: 30px; } - -h3, .h3 { - font-size: 24px; } - -h4, .h4 { - font-size: 18px; } - -h5, .h5 { - font-size: 14px; } - -h6, .h6 { - font-size: 12px; } - -h1 small, .h1 small { - font-size: 24px; } - -h2 small, .h2 small { - font-size: 18px; } - -h3 small, .h3 small, -h4 small, .h4 small { - font-size: 14px; } - -dl { - margin-bottom: 20px; } - -dt, -dd { - line-height: 1.42857; } - -dt { - font-weight: bold; } - -blockquote { - margin: 0 0 20px; - padding: 10px 20px; - border-left: 5px solid gray; } - blockquote p { - font-weight: 300; - font-size: 17.5px; - line-height: 1.25; } - blockquote p:last-child { - margin-bottom: 0; } - blockquote small { - display: block; - line-height: 1.42857; } - blockquote small:before { - content: '\2014 \00A0'; } - -q:before, -q:after, -blockquote:before, -blockquote:after { - content: ""; } - -address { - display: block; - margin-bottom: 20px; - font-style: normal; - line-height: 1.42857; } - -a { - color: #387ef5; } - -a.subdued { - padding-right: 10px; - color: #888; - text-decoration: none; } - a.subdued:hover { - text-decoration: none; } - a.subdued:last-child { - padding-right: 0; } - -/** - * Action Sheets - * -------------------------------------------------- - */ -.action-sheet-backdrop { - -webkit-transition: background-color 150ms ease-in-out; - transition: background-color 150ms ease-in-out; - position: fixed; - top: 0; - left: 0; - z-index: 11; - width: 100%; - height: 100%; - background-color: transparent; } - .action-sheet-backdrop.active { - background-color: rgba(0, 0, 0, 0.4); } - -.action-sheet-wrapper { - -webkit-transform: translate3d(0, 100%, 0); - transform: translate3d(0, 100%, 0); - -webkit-transition: all cubic-bezier(0.36, 0.66, 0.04, 1) 500ms; - transition: all cubic-bezier(0.36, 0.66, 0.04, 1) 500ms; - position: absolute; - bottom: 0; - left: 0; - right: 0; - width: 100%; - max-width: 500px; - margin: auto; } - -.action-sheet-up { - -webkit-transform: translate3d(0, 0, 0); - transform: translate3d(0, 0, 0); } - -.action-sheet { - margin-left: 8px; - margin-right: 8px; - width: auto; - z-index: 11; - overflow: hidden; } - .action-sheet .button { - display: block; - padding: 1px; - width: 100%; - border-radius: 0; - border-color: #d1d3d6; - background-color: transparent; - color: #007aff; - font-size: 21px; } - .action-sheet .button:hover { - color: #007aff; } - .action-sheet .button.destructive { - color: #ff3b30; } - .action-sheet .button.destructive:hover { - color: #ff3b30; } - .action-sheet .button.active, .action-sheet .button.activated { - box-shadow: none; - border-color: #d1d3d6; - color: #007aff; - background: #e4e5e7; } - -.action-sheet-has-icons .icon { - position: absolute; - left: 16px; } - -.action-sheet-title { - padding: 16px; - color: #8f8f8f; - text-align: center; - font-size: 13px; } - -.action-sheet-group { - margin-bottom: 8px; - border-radius: 4px; - background-color: #fff; - overflow: hidden; } - .action-sheet-group .button { - border-width: 1px 0px 0px 0px; } - .action-sheet-group .button:first-child:last-child { - border-width: 0; } - -.action-sheet-options { - background: #f1f2f3; } - -.action-sheet-cancel .button { - font-weight: 500; } - -.action-sheet-open { - pointer-events: none; } - .action-sheet-open.modal-open .modal { - pointer-events: none; } - .action-sheet-open .action-sheet-backdrop { - pointer-events: auto; } - -.platform-android .action-sheet-backdrop.active { - background-color: rgba(0, 0, 0, 0.2); } - -.platform-android .action-sheet { - margin: 0; } - .platform-android .action-sheet .action-sheet-title, - .platform-android .action-sheet .button { - text-align: left; - border-color: transparent; - font-size: 16px; - color: inherit; } - .platform-android .action-sheet .action-sheet-title { - font-size: 14px; - padding: 16px; - color: #666; } - .platform-android .action-sheet .button.active, - .platform-android .action-sheet .button.activated { - background: #e8e8e8; } - -.platform-android .action-sheet-group { - margin: 0; - border-radius: 0; - background-color: #fafafa; } - -.platform-android .action-sheet-cancel { - display: none; } - -.platform-android .action-sheet-has-icons .button { - padding-left: 56px; } - -.backdrop { - position: fixed; - top: 0; - left: 0; - z-index: 11; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.4); - visibility: hidden; - opacity: 0; - -webkit-transition: 0.1s opacity linear; - transition: 0.1s opacity linear; } - .backdrop.visible { - visibility: visible; } - .backdrop.active { - opacity: 1; } - -/** - * Bar (Headers and Footers) - * -------------------------------------------------- - */ -.bar { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-transform: translate3d(0, 0, 0); - transform: translate3d(0, 0, 0); - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - position: absolute; - right: 0; - left: 0; - z-index: 9; - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; - padding: 5px; - width: 100%; - height: 44px; - border-width: 0; - border-style: solid; - border-top: 1px solid transparent; - border-bottom: 1px solid #ddd; - background-color: white; - /* border-width: 1px will actually create 2 device pixels on retina */ - /* this nifty trick sets an actual 1px border on hi-res displays */ - background-size: 0; } - @media (min--moz-device-pixel-ratio: 1.5), (-webkit-min-device-pixel-ratio: 1.5), (min-device-pixel-ratio: 1.5), (min-resolution: 144dpi), (min-resolution: 1.5dppx) { - .bar { - border: none; - background-image: linear-gradient(0deg, #ddd, #ddd 50%, transparent 50%); - background-position: bottom; - background-size: 100% 1px; - background-repeat: no-repeat; } } - .bar.bar-clear { - border: none; - background: none; - color: #fff; } - .bar.bar-clear .button { - color: #fff; } - .bar.bar-clear .title { - color: #fff; } - .bar.item-input-inset .item-input-wrapper { - margin-top: -1px; } - .bar.item-input-inset .item-input-wrapper input { - padding-left: 8px; - width: 94%; - height: 28px; - background: transparent; } - .bar.bar-light { - border-color: #ddd; - background-color: white; - background-image: linear-gradient(0deg, #ddd, #ddd 50%, transparent 50%); - color: #444; } - .bar.bar-light .title { - color: #444; } - .bar.bar-light.bar-footer { - background-image: linear-gradient(180deg, #ddd, #ddd 50%, transparent 50%); } - .bar.bar-stable { - border-color: #b2b2b2; - background-color: #f8f8f8; - background-image: linear-gradient(0deg, #b2b2b2, #b2b2b2 50%, transparent 50%); - color: #444; } - .bar.bar-stable .title { - color: #444; } - .bar.bar-stable.bar-footer { - background-image: linear-gradient(180deg, #b2b2b2, #b2b2b2 50%, transparent 50%); } - .bar.bar-positive { - border-color: #0c60ee; - background-color: #387ef5; - background-image: linear-gradient(0deg, #0c60ee, #0c60ee 50%, transparent 50%); - color: #fff; } - .bar.bar-positive .title { - color: #fff; } - .bar.bar-positive.bar-footer { - background-image: linear-gradient(180deg, #0c60ee, #0c60ee 50%, transparent 50%); } - .bar.bar-calm { - border-color: #0a9dc7; - background-color: #11c1f3; - background-image: linear-gradient(0deg, #0a9dc7, #0a9dc7 50%, transparent 50%); - color: #fff; } - .bar.bar-calm .title { - color: #fff; } - .bar.bar-calm.bar-footer { - background-image: linear-gradient(180deg, #0a9dc7, #0a9dc7 50%, transparent 50%); } - .bar.bar-assertive { - border-color: #e42112; - background-color: #ef473a; - background-image: linear-gradient(0deg, #e42112, #e42112 50%, transparent 50%); - color: #fff; } - .bar.bar-assertive .title { - color: #fff; } - .bar.bar-assertive.bar-footer { - background-image: linear-gradient(180deg, #e42112, #e42112 50%, transparent 50%); } - .bar.bar-balanced { - border-color: #28a54c; - background-color: #33cd5f; - background-image: linear-gradient(0deg, #28a54c, #28a54c 50%, transparent 50%); - color: #fff; } - .bar.bar-balanced .title { - color: #fff; } - .bar.bar-balanced.bar-footer { - background-image: linear-gradient(180deg, #28a54c, #28a54c 50%, transparent 50%); } - .bar.bar-energized { - border-color: #e6b500; - background-color: #ffc900; - background-image: linear-gradient(0deg, #e6b500, #e6b500 50%, transparent 50%); - color: #fff; } - .bar.bar-energized .title { - color: #fff; } - .bar.bar-energized.bar-footer { - background-image: linear-gradient(180deg, #e6b500, #e6b500 50%, transparent 50%); } - .bar.bar-royal { - border-color: #6b46e5; - background-color: #886aea; - background-image: linear-gradient(0deg, #6b46e5, #6b46e5 50%, transparent 50%); - color: #fff; } - .bar.bar-royal .title { - color: #fff; } - .bar.bar-royal.bar-footer { - background-image: linear-gradient(180deg, #6b46e5, #6b46e5 50%, transparent 50%); } - .bar.bar-dark { - border-color: #111; - background-color: #444444; - background-image: linear-gradient(0deg, #111, #111 50%, transparent 50%); - color: #fff; } - .bar.bar-dark .title { - color: #fff; } - .bar.bar-dark.bar-footer { - background-image: linear-gradient(180deg, #111, #111 50%, transparent 50%); } - .bar .title { - display: block; - position: absolute; - top: 0; - right: 0; - left: 0; - z-index: 0; - overflow: hidden; - margin: 0 10px; - min-width: 30px; - height: 43px; - text-align: center; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 17px; - font-weight: 500; - line-height: 44px; } - .bar .title.title-left { - text-align: left; } - .bar .title.title-right { - text-align: right; } - .bar .title a { - color: inherit; } - .bar .button, .bar button { - z-index: 1; - padding: 0 8px; - min-width: initial; - min-height: 31px; - font-weight: 400; - font-size: 13px; - line-height: 32px; } - .bar .button.button-icon:before, - .bar .button .icon:before, .bar .button.icon:before, .bar .button.icon-left:before, .bar .button.icon-right:before, .bar button.button-icon:before, - .bar button .icon:before, .bar button.icon:before, .bar button.icon-left:before, .bar button.icon-right:before { - padding-right: 2px; - padding-left: 2px; - font-size: 20px; - line-height: 32px; } - .bar .button.button-icon, .bar button.button-icon { - font-size: 17px; } - .bar .button.button-icon .icon:before, .bar .button.button-icon:before, .bar .button.button-icon.icon-left:before, .bar .button.button-icon.icon-right:before, .bar button.button-icon .icon:before, .bar button.button-icon:before, .bar button.button-icon.icon-left:before, .bar button.button-icon.icon-right:before { - vertical-align: top; - font-size: 32px; - line-height: 32px; } - .bar .button.button-clear, .bar button.button-clear { - padding-right: 2px; - padding-left: 2px; - font-weight: 300; - font-size: 17px; } - .bar .button.button-clear .icon:before, .bar .button.button-clear.icon:before, .bar .button.button-clear.icon-left:before, .bar .button.button-clear.icon-right:before, .bar button.button-clear .icon:before, .bar button.button-clear.icon:before, .bar button.button-clear.icon-left:before, .bar button.button-clear.icon-right:before { - font-size: 32px; - line-height: 32px; } - .bar .button.back-button, .bar button.back-button { - display: block; - margin-right: 5px; - padding: 0; - white-space: nowrap; - font-weight: 400; } - .bar .button.back-button.active, .bar .button.back-button.activated, .bar button.back-button.active, .bar button.back-button.activated { - opacity: 0.2; } - .bar .button-bar > .button, - .bar .buttons > .button { - min-height: 31px; - line-height: 32px; } - .bar .button-bar + .button, - .bar .button + .button-bar { - margin-left: 5px; } - .bar .buttons, - .bar .buttons.primary-buttons, - .bar .buttons.secondary-buttons { - display: inherit; } - .bar .buttons span { - display: inline-block; } - .bar .buttons-left span { - margin-right: 5px; - display: inherit; } - .bar .buttons-right span { - margin-left: 5px; - display: inherit; } - .bar .title + .button:last-child, - .bar > .button + .button:last-child, - .bar > .button.pull-right, - .bar .buttons.pull-right, - .bar .title + .buttons { - position: absolute; - top: 5px; - right: 5px; - bottom: 5px; } - -.platform-android .nav-bar-has-subheader .bar { - background-image: none; } - -.platform-android .bar .back-button .icon:before { - font-size: 24px; } - -.platform-android .bar .title { - font-size: 19px; - line-height: 44px; } - -.bar-light .button { - border-color: #ddd; - background-color: white; - color: #444; } - .bar-light .button:hover { - color: #444; - text-decoration: none; } - .bar-light .button.active, .bar-light .button.activated { - border-color: #ccc; - background-color: #fafafa; } - .bar-light .button.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #444; - font-size: 17px; } - .bar-light .button.button-icon { - border-color: transparent; - background: none; } - -.bar-stable .button { - border-color: #b2b2b2; - background-color: #f8f8f8; - color: #444; } - .bar-stable .button:hover { - color: #444; - text-decoration: none; } - .bar-stable .button.active, .bar-stable .button.activated { - border-color: #a2a2a2; - background-color: #e5e5e5; } - .bar-stable .button.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #444; - font-size: 17px; } - .bar-stable .button.button-icon { - border-color: transparent; - background: none; } - -.bar-positive .button { - border-color: #0c60ee; - background-color: #387ef5; - color: #fff; } - .bar-positive .button:hover { - color: #fff; - text-decoration: none; } - .bar-positive .button.active, .bar-positive .button.activated { - border-color: #0c60ee; - background-color: #0c60ee; } - .bar-positive .button.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #fff; - font-size: 17px; } - .bar-positive .button.button-icon { - border-color: transparent; - background: none; } - -.bar-calm .button { - border-color: #0a9dc7; - background-color: #11c1f3; - color: #fff; } - .bar-calm .button:hover { - color: #fff; - text-decoration: none; } - .bar-calm .button.active, .bar-calm .button.activated { - border-color: #0a9dc7; - background-color: #0a9dc7; } - .bar-calm .button.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #fff; - font-size: 17px; } - .bar-calm .button.button-icon { - border-color: transparent; - background: none; } - -.bar-assertive .button { - border-color: #e42112; - background-color: #ef473a; - color: #fff; } - .bar-assertive .button:hover { - color: #fff; - text-decoration: none; } - .bar-assertive .button.active, .bar-assertive .button.activated { - border-color: #e42112; - background-color: #e42112; } - .bar-assertive .button.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #fff; - font-size: 17px; } - .bar-assertive .button.button-icon { - border-color: transparent; - background: none; } - -.bar-balanced .button { - border-color: #28a54c; - background-color: #33cd5f; - color: #fff; } - .bar-balanced .button:hover { - color: #fff; - text-decoration: none; } - .bar-balanced .button.active, .bar-balanced .button.activated { - border-color: #28a54c; - background-color: #28a54c; } - .bar-balanced .button.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #fff; - font-size: 17px; } - .bar-balanced .button.button-icon { - border-color: transparent; - background: none; } - -.bar-energized .button { - border-color: #e6b500; - background-color: #ffc900; - color: #fff; } - .bar-energized .button:hover { - color: #fff; - text-decoration: none; } - .bar-energized .button.active, .bar-energized .button.activated { - border-color: #e6b500; - background-color: #e6b500; } - .bar-energized .button.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #fff; - font-size: 17px; } - .bar-energized .button.button-icon { - border-color: transparent; - background: none; } - -.bar-royal .button { - border-color: #6b46e5; - background-color: #886aea; - color: #fff; } - .bar-royal .button:hover { - color: #fff; - text-decoration: none; } - .bar-royal .button.active, .bar-royal .button.activated { - border-color: #6b46e5; - background-color: #6b46e5; } - .bar-royal .button.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #fff; - font-size: 17px; } - .bar-royal .button.button-icon { - border-color: transparent; - background: none; } - -.bar-dark .button { - border-color: #111; - background-color: #444444; - color: #fff; } - .bar-dark .button:hover { - color: #fff; - text-decoration: none; } - .bar-dark .button.active, .bar-dark .button.activated { - border-color: #000; - background-color: #262626; } - .bar-dark .button.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #fff; - font-size: 17px; } - .bar-dark .button.button-icon { - border-color: transparent; - background: none; } - -.bar-header { - top: 0; - border-top-width: 0; - border-bottom-width: 1px; } - .bar-header.has-tabs-top { - border-bottom-width: 0px; - background-image: none; } - -.tabs-top .bar-header { - border-bottom-width: 0px; - background-image: none; } - -.bar-footer { - bottom: 0; - border-top-width: 1px; - border-bottom-width: 0; - background-position: top; - height: 44px; } - .bar-footer.item-input-inset { - position: absolute; } - .bar-footer .title { - height: 43px; - line-height: 44px; } - -.bar-tabs { - padding: 0; } - -.bar-subheader { - top: 44px; - height: 44px; } - .bar-subheader .title { - height: 43px; - line-height: 44px; } - -.bar-subfooter { - bottom: 44px; - height: 44px; } - .bar-subfooter .title { - height: 43px; - line-height: 44px; } - -.nav-bar-block { - position: absolute; - top: 0; - right: 0; - left: 0; - z-index: 9; } - -.bar .back-button.hide, -.bar .buttons .hide { - display: none; } - -.nav-bar-tabs-top .bar { - background-image: none; } - -/** - * Tabs - * -------------------------------------------------- - * A navigation bar with any number of tab items supported. - */ -.tabs { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-direction: normal; - -webkit-box-orient: horizontal; - -webkit-flex-direction: horizontal; - -moz-flex-direction: horizontal; - -ms-flex-direction: horizontal; - flex-direction: horizontal; - -webkit-box-pack: center; - -ms-flex-pack: center; - -webkit-justify-content: center; - -moz-justify-content: center; - justify-content: center; - -webkit-transform: translate3d(0, 0, 0); - transform: translate3d(0, 0, 0); - border-color: #b2b2b2; - background-color: #f8f8f8; - background-image: linear-gradient(0deg, #b2b2b2, #b2b2b2 50%, transparent 50%); - color: #444; - position: absolute; - bottom: 0; - z-index: 5; - width: 100%; - height: 49px; - border-style: solid; - border-top-width: 1px; - background-size: 0; - line-height: 49px; } - .tabs .tab-item .badge { - background-color: #444; - color: #f8f8f8; } - @media (min--moz-device-pixel-ratio: 1.5), (-webkit-min-device-pixel-ratio: 1.5), (min-device-pixel-ratio: 1.5), (min-resolution: 144dpi), (min-resolution: 1.5dppx) { - .tabs { - padding-top: 2px; - border-top: none !important; - border-bottom: none; - background-position: top; - background-size: 100% 1px; - background-repeat: no-repeat; } } - -/* Allow parent element of tabs to define color, or just the tab itself */ -.tabs-light > .tabs, -.tabs.tabs-light { - border-color: #ddd; - background-color: #fff; - background-image: linear-gradient(0deg, #ddd, #ddd 50%, transparent 50%); - color: #444; } - .tabs-light > .tabs .tab-item .badge, - .tabs.tabs-light .tab-item .badge { - background-color: #444; - color: #fff; } - -.tabs-stable > .tabs, -.tabs.tabs-stable { - border-color: #b2b2b2; - background-color: #f8f8f8; - background-image: linear-gradient(0deg, #b2b2b2, #b2b2b2 50%, transparent 50%); - color: #444; } - .tabs-stable > .tabs .tab-item .badge, - .tabs.tabs-stable .tab-item .badge { - background-color: #444; - color: #f8f8f8; } - -.tabs-positive > .tabs, -.tabs.tabs-positive { - border-color: #0c60ee; - background-color: #387ef5; - background-image: linear-gradient(0deg, #0c60ee, #0c60ee 50%, transparent 50%); - color: #fff; } - .tabs-positive > .tabs .tab-item .badge, - .tabs.tabs-positive .tab-item .badge { - background-color: #fff; - color: #387ef5; } - -.tabs-calm > .tabs, -.tabs.tabs-calm { - border-color: #0a9dc7; - background-color: #11c1f3; - background-image: linear-gradient(0deg, #0a9dc7, #0a9dc7 50%, transparent 50%); - color: #fff; } - .tabs-calm > .tabs .tab-item .badge, - .tabs.tabs-calm .tab-item .badge { - background-color: #fff; - color: #11c1f3; } - -.tabs-assertive > .tabs, -.tabs.tabs-assertive { - border-color: #e42112; - background-color: #ef473a; - background-image: linear-gradient(0deg, #e42112, #e42112 50%, transparent 50%); - color: #fff; } - .tabs-assertive > .tabs .tab-item .badge, - .tabs.tabs-assertive .tab-item .badge { - background-color: #fff; - color: #ef473a; } - -.tabs-balanced > .tabs, -.tabs.tabs-balanced { - border-color: #28a54c; - background-color: #33cd5f; - background-image: linear-gradient(0deg, #28a54c, #28a54c 50%, transparent 50%); - color: #fff; } - .tabs-balanced > .tabs .tab-item .badge, - .tabs.tabs-balanced .tab-item .badge { - background-color: #fff; - color: #33cd5f; } - -.tabs-energized > .tabs, -.tabs.tabs-energized { - border-color: #e6b500; - background-color: #ffc900; - background-image: linear-gradient(0deg, #e6b500, #e6b500 50%, transparent 50%); - color: #fff; } - .tabs-energized > .tabs .tab-item .badge, - .tabs.tabs-energized .tab-item .badge { - background-color: #fff; - color: #ffc900; } - -.tabs-royal > .tabs, -.tabs.tabs-royal { - border-color: #6b46e5; - background-color: #886aea; - background-image: linear-gradient(0deg, #6b46e5, #6b46e5 50%, transparent 50%); - color: #fff; } - .tabs-royal > .tabs .tab-item .badge, - .tabs.tabs-royal .tab-item .badge { - background-color: #fff; - color: #886aea; } - -.tabs-dark > .tabs, -.tabs.tabs-dark { - border-color: #111; - background-color: #444; - background-image: linear-gradient(0deg, #111, #111 50%, transparent 50%); - color: #fff; } - .tabs-dark > .tabs .tab-item .badge, - .tabs.tabs-dark .tab-item .badge { - background-color: #fff; - color: #444; } - -.tabs-striped .tabs { - background-color: white; - background-image: none; - border: none; - border-bottom: 1px solid #ddd; - padding-top: 2px; } - -.tabs-striped .tab-item.tab-item-active, .tabs-striped .tab-item.active, .tabs-striped .tab-item.activated { - margin-top: -2px; - border-style: solid; - border-width: 2px 0 0 0; - border-color: #444; } - .tabs-striped .tab-item.tab-item-active .badge, .tabs-striped .tab-item.active .badge, .tabs-striped .tab-item.activated .badge { - top: 2px; - opacity: 1; } - -.tabs-striped.tabs-light .tabs { - background-color: #fff; } - -.tabs-striped.tabs-light .tab-item { - color: rgba(68, 68, 68, 0.4); - opacity: 1; } - .tabs-striped.tabs-light .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-light .tab-item.tab-item-active, .tabs-striped.tabs-light .tab-item.active, .tabs-striped.tabs-light .tab-item.activated { - margin-top: -2px; - color: #444; - border-style: solid; - border-width: 2px 0 0 0; - border-color: #444; } - -.tabs-striped.tabs-top .tab-item.tab-item-active .badge, .tabs-striped.tabs-top .tab-item.active .badge, .tabs-striped.tabs-top .tab-item.activated .badge { - top: 4%; } - -.tabs-striped.tabs-stable .tabs { - background-color: #f8f8f8; } - -.tabs-striped.tabs-stable .tab-item { - color: rgba(68, 68, 68, 0.4); - opacity: 1; } - .tabs-striped.tabs-stable .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-stable .tab-item.tab-item-active, .tabs-striped.tabs-stable .tab-item.active, .tabs-striped.tabs-stable .tab-item.activated { - margin-top: -2px; - color: #444; - border-style: solid; - border-width: 2px 0 0 0; - border-color: #444; } - -.tabs-striped.tabs-top .tab-item.tab-item-active .badge, .tabs-striped.tabs-top .tab-item.active .badge, .tabs-striped.tabs-top .tab-item.activated .badge { - top: 4%; } - -.tabs-striped.tabs-positive .tabs { - background-color: #387ef5; } - -.tabs-striped.tabs-positive .tab-item { - color: rgba(255, 255, 255, 0.4); - opacity: 1; } - .tabs-striped.tabs-positive .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-positive .tab-item.tab-item-active, .tabs-striped.tabs-positive .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; } - -.tabs-striped.tabs-top .tab-item.tab-item-active .badge, .tabs-striped.tabs-top .tab-item.active .badge, .tabs-striped.tabs-top .tab-item.activated .badge { - top: 4%; } - -.tabs-striped.tabs-calm .tabs { - background-color: #11c1f3; } - -.tabs-striped.tabs-calm .tab-item { - color: rgba(255, 255, 255, 0.4); - opacity: 1; } - .tabs-striped.tabs-calm .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-calm .tab-item.tab-item-active, .tabs-striped.tabs-calm .tab-item.active, .tabs-striped.tabs-calm .tab-item.activated { - margin-top: -2px; - color: #fff; - border-style: solid; - border-width: 2px 0 0 0; - border-color: #fff; } - -.tabs-striped.tabs-top .tab-item.tab-item-active .badge, .tabs-striped.tabs-top .tab-item.active .badge, .tabs-striped.tabs-top .tab-item.activated .badge { - top: 4%; } - -.tabs-striped.tabs-assertive .tabs { - background-color: #ef473a; } - -.tabs-striped.tabs-assertive .tab-item { - color: rgba(255, 255, 255, 0.4); - opacity: 1; } - .tabs-striped.tabs-assertive .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-assertive .tab-item.tab-item-active, .tabs-striped.tabs-assertive .tab-item.active, .tabs-striped.tabs-assertive .tab-item.activated { - margin-top: -2px; - color: #fff; - border-style: solid; - border-width: 2px 0 0 0; - border-color: #fff; } - -.tabs-striped.tabs-top .tab-item.tab-item-active .badge, .tabs-striped.tabs-top .tab-item.active .badge, .tabs-striped.tabs-top .tab-item.activated .badge { - top: 4%; } - -.tabs-striped.tabs-balanced .tabs { - background-color: #33cd5f; } - -.tabs-striped.tabs-balanced .tab-item { - color: rgba(255, 255, 255, 0.4); - opacity: 1; } - .tabs-striped.tabs-balanced .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-balanced .tab-item.tab-item-active, .tabs-striped.tabs-balanced .tab-item.active, .tabs-striped.tabs-balanced .tab-item.activated { - margin-top: -2px; - color: #fff; - border-style: solid; - border-width: 2px 0 0 0; - border-color: #fff; } - -.tabs-striped.tabs-top .tab-item.tab-item-active .badge, .tabs-striped.tabs-top .tab-item.active .badge, .tabs-striped.tabs-top .tab-item.activated .badge { - top: 4%; } - -.tabs-striped.tabs-energized .tabs { - background-color: #ffc900; } - -.tabs-striped.tabs-energized .tab-item { - color: rgba(255, 255, 255, 0.4); - opacity: 1; } - .tabs-striped.tabs-energized .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-energized .tab-item.tab-item-active, .tabs-striped.tabs-energized .tab-item.active, .tabs-striped.tabs-energized .tab-item.activated { - margin-top: -2px; - color: #fff; - border-style: solid; - border-width: 2px 0 0 0; - border-color: #fff; } - -.tabs-striped.tabs-top .tab-item.tab-item-active .badge, .tabs-striped.tabs-top .tab-item.active .badge, .tabs-striped.tabs-top .tab-item.activated .badge { - top: 4%; } - -.tabs-striped.tabs-royal .tabs { - background-color: #886aea; } - -.tabs-striped.tabs-royal .tab-item { - color: rgba(255, 255, 255, 0.4); - opacity: 1; } - .tabs-striped.tabs-royal .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-royal .tab-item.tab-item-active, .tabs-striped.tabs-royal .tab-item.active, .tabs-striped.tabs-royal .tab-item.activated { - margin-top: -2px; - color: #fff; - border-style: solid; - border-width: 2px 0 0 0; - border-color: #fff; } - -.tabs-striped.tabs-top .tab-item.tab-item-active .badge, .tabs-striped.tabs-top .tab-item.active .badge, .tabs-striped.tabs-top .tab-item.activated .badge { - top: 4%; } - -.tabs-striped.tabs-dark .tabs { - background-color: #444; } - -.tabs-striped.tabs-dark .tab-item { - color: rgba(255, 255, 255, 0.4); - opacity: 1; } - .tabs-striped.tabs-dark .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-dark .tab-item.tab-item-active, .tabs-striped.tabs-dark .tab-item.active, .tabs-striped.tabs-dark .tab-item.activated { - margin-top: -2px; - color: #fff; - border-style: solid; - border-width: 2px 0 0 0; - border-color: #fff; } - -.tabs-striped.tabs-top .tab-item.tab-item-active .badge, .tabs-striped.tabs-top .tab-item.active .badge, .tabs-striped.tabs-top .tab-item.activated .badge { - top: 4%; } - -.tabs-striped.tabs-background-light .tabs { - background-color: #fff; - background-image: none; } - -.tabs-striped.tabs-background-stable .tabs { - background-color: #f8f8f8; - background-image: none; } - -.tabs-striped.tabs-background-positive .tabs { - background-color: #387ef5; - background-image: none; } - -.tabs-striped.tabs-background-calm .tabs { - background-color: #11c1f3; - background-image: none; } - -.tabs-striped.tabs-background-assertive .tabs { - background-color: #ef473a; - background-image: none; } - -.tabs-striped.tabs-background-balanced .tabs { - background-color: #33cd5f; - background-image: none; } - -.tabs-striped.tabs-background-energized .tabs { - background-color: #ffc900; - background-image: none; } - -.tabs-striped.tabs-background-royal .tabs { - background-color: #886aea; - background-image: none; } - -.tabs-striped.tabs-background-dark .tabs { - background-color: #444; - background-image: none; } - -.tabs-striped.tabs-color-light .tab-item { - color: rgba(255, 255, 255, 0.4); - opacity: 1; } - .tabs-striped.tabs-color-light .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-color-light .tab-item.tab-item-active, .tabs-striped.tabs-color-light .tab-item.active, .tabs-striped.tabs-color-light .tab-item.activated { - margin-top: -2px; - color: #fff; - border: 0 solid #fff; - border-top-width: 2px; } - .tabs-striped.tabs-color-light .tab-item.tab-item-active .badge, .tabs-striped.tabs-color-light .tab-item.active .badge, .tabs-striped.tabs-color-light .tab-item.activated .badge { - top: 2px; - opacity: 1; } - -.tabs-striped.tabs-color-stable .tab-item { - color: rgba(248, 248, 248, 0.4); - opacity: 1; } - .tabs-striped.tabs-color-stable .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-color-stable .tab-item.tab-item-active, .tabs-striped.tabs-color-stable .tab-item.active, .tabs-striped.tabs-color-stable .tab-item.activated { - margin-top: -2px; - color: #f8f8f8; - border: 0 solid #f8f8f8; - border-top-width: 2px; } - .tabs-striped.tabs-color-stable .tab-item.tab-item-active .badge, .tabs-striped.tabs-color-stable .tab-item.active .badge, .tabs-striped.tabs-color-stable .tab-item.activated .badge { - top: 2px; - opacity: 1; } - -.tabs-striped.tabs-color-positive .tab-item { - color: rgba(56, 126, 245, 0.4); - opacity: 1; } - .tabs-striped.tabs-color-positive .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-color-positive .tab-item.tab-item-active, .tabs-striped.tabs-color-positive .tab-item.active, .tabs-striped.tabs-color-positive .tab-item.activated { - margin-top: -2px; - color: #387ef5; - border: 0 solid #387ef5; - border-top-width: 2px; } - .tabs-striped.tabs-color-positive .tab-item.tab-item-active .badge, .tabs-striped.tabs-color-positive .tab-item.active .badge, .tabs-striped.tabs-color-positive .tab-item.activated .badge { - top: 2px; - opacity: 1; } - -.tabs-striped.tabs-color-calm .tab-item { - color: rgba(17, 193, 243, 0.4); - opacity: 1; } - .tabs-striped.tabs-color-calm .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-color-calm .tab-item.tab-item-active, .tabs-striped.tabs-color-calm .tab-item.active, .tabs-striped.tabs-color-calm .tab-item.activated { - margin-top: -2px; - color: #11c1f3; - border: 0 solid #11c1f3; - border-top-width: 2px; } - .tabs-striped.tabs-color-calm .tab-item.tab-item-active .badge, .tabs-striped.tabs-color-calm .tab-item.active .badge, .tabs-striped.tabs-color-calm .tab-item.activated .badge { - top: 2px; - opacity: 1; } - -.tabs-striped.tabs-color-assertive .tab-item { - color: rgba(239, 71, 58, 0.4); - opacity: 1; } - .tabs-striped.tabs-color-assertive .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-color-assertive .tab-item.tab-item-active, .tabs-striped.tabs-color-assertive .tab-item.active, .tabs-striped.tabs-color-assertive .tab-item.activated { - margin-top: -2px; - color: #ef473a; - border: 0 solid #ef473a; - border-top-width: 2px; } - .tabs-striped.tabs-color-assertive .tab-item.tab-item-active .badge, .tabs-striped.tabs-color-assertive .tab-item.active .badge, .tabs-striped.tabs-color-assertive .tab-item.activated .badge { - top: 2px; - opacity: 1; } - -.tabs-striped.tabs-color-balanced .tab-item { - color: rgba(51, 205, 95, 0.4); - opacity: 1; } - .tabs-striped.tabs-color-balanced .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-color-balanced .tab-item.tab-item-active, .tabs-striped.tabs-color-balanced .tab-item.active, .tabs-striped.tabs-color-balanced .tab-item.activated { - margin-top: -2px; - color: #33cd5f; - border: 0 solid #33cd5f; - border-top-width: 2px; } - .tabs-striped.tabs-color-balanced .tab-item.tab-item-active .badge, .tabs-striped.tabs-color-balanced .tab-item.active .badge, .tabs-striped.tabs-color-balanced .tab-item.activated .badge { - top: 2px; - opacity: 1; } - -.tabs-striped.tabs-color-energized .tab-item { - color: rgba(255, 201, 0, 0.4); - opacity: 1; } - .tabs-striped.tabs-color-energized .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-color-energized .tab-item.tab-item-active, .tabs-striped.tabs-color-energized .tab-item.active, .tabs-striped.tabs-color-energized .tab-item.activated { - margin-top: -2px; - color: #ffc900; - border: 0 solid #ffc900; - border-top-width: 2px; } - .tabs-striped.tabs-color-energized .tab-item.tab-item-active .badge, .tabs-striped.tabs-color-energized .tab-item.active .badge, .tabs-striped.tabs-color-energized .tab-item.activated .badge { - top: 2px; - opacity: 1; } - -.tabs-striped.tabs-color-royal .tab-item { - color: rgba(136, 106, 234, 0.4); - opacity: 1; } - .tabs-striped.tabs-color-royal .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-color-royal .tab-item.tab-item-active, .tabs-striped.tabs-color-royal .tab-item.active, .tabs-striped.tabs-color-royal .tab-item.activated { - margin-top: -2px; - color: #886aea; - border: 0 solid #886aea; - border-top-width: 2px; } - .tabs-striped.tabs-color-royal .tab-item.tab-item-active .badge, .tabs-striped.tabs-color-royal .tab-item.active .badge, .tabs-striped.tabs-color-royal .tab-item.activated .badge { - top: 2px; - opacity: 1; } - -.tabs-striped.tabs-color-dark .tab-item { - color: rgba(68, 68, 68, 0.4); - opacity: 1; } - .tabs-striped.tabs-color-dark .tab-item .badge { - opacity: 0.4; } - .tabs-striped.tabs-color-dark .tab-item.tab-item-active, .tabs-striped.tabs-color-dark .tab-item.active, .tabs-striped.tabs-color-dark .tab-item.activated { - margin-top: -2px; - color: #444; - border: 0 solid #444; - border-top-width: 2px; } - .tabs-striped.tabs-color-dark .tab-item.tab-item-active .badge, .tabs-striped.tabs-color-dark .tab-item.active .badge, .tabs-striped.tabs-color-dark .tab-item.activated .badge { - top: 2px; - opacity: 1; } - -.tabs-background-light .tabs, -.tabs-background-light > .tabs { - background-color: #fff; - background-image: linear-gradient(0deg, #ddd, #ddd 50%, transparent 50%); - border-color: #ddd; } - -.tabs-background-stable .tabs, -.tabs-background-stable > .tabs { - background-color: #f8f8f8; - background-image: linear-gradient(0deg, #b2b2b2, #b2b2b2 50%, transparent 50%); - border-color: #b2b2b2; } - -.tabs-background-positive .tabs, -.tabs-background-positive > .tabs { - background-color: #387ef5; - background-image: linear-gradient(0deg, #0c60ee, #0c60ee 50%, transparent 50%); - border-color: #0c60ee; } - -.tabs-background-calm .tabs, -.tabs-background-calm > .tabs { - background-color: #11c1f3; - background-image: linear-gradient(0deg, #0a9dc7, #0a9dc7 50%, transparent 50%); - border-color: #0a9dc7; } - -.tabs-background-assertive .tabs, -.tabs-background-assertive > .tabs { - background-color: #ef473a; - background-image: linear-gradient(0deg, #e42112, #e42112 50%, transparent 50%); - border-color: #e42112; } - -.tabs-background-balanced .tabs, -.tabs-background-balanced > .tabs { - background-color: #33cd5f; - background-image: linear-gradient(0deg, #28a54c, #28a54c 50%, transparent 50%); - border-color: #28a54c; } - -.tabs-background-energized .tabs, -.tabs-background-energized > .tabs { - background-color: #ffc900; - background-image: linear-gradient(0deg, #e6b500, #e6b500 50%, transparent 50%); - border-color: #e6b500; } - -.tabs-background-royal .tabs, -.tabs-background-royal > .tabs { - background-color: #886aea; - background-image: linear-gradient(0deg, #6b46e5, #6b46e5 50%, transparent 50%); - border-color: #6b46e5; } - -.tabs-background-dark .tabs, -.tabs-background-dark > .tabs { - background-color: #444; - background-image: linear-gradient(0deg, #111, #111 50%, transparent 50%); - border-color: #111; } - -.tabs-color-light .tab-item { - color: rgba(255, 255, 255, 0.4); - opacity: 1; } - .tabs-color-light .tab-item .badge { - opacity: 0.4; } - .tabs-color-light .tab-item.tab-item-active, .tabs-color-light .tab-item.active, .tabs-color-light .tab-item.activated { - color: #fff; - border: 0 solid #fff; } - .tabs-color-light .tab-item.tab-item-active .badge, .tabs-color-light .tab-item.active .badge, .tabs-color-light .tab-item.activated .badge { - opacity: 1; } - -.tabs-color-stable .tab-item { - color: rgba(248, 248, 248, 0.4); - opacity: 1; } - .tabs-color-stable .tab-item .badge { - opacity: 0.4; } - .tabs-color-stable .tab-item.tab-item-active, .tabs-color-stable .tab-item.active, .tabs-color-stable .tab-item.activated { - color: #f8f8f8; - border: 0 solid #f8f8f8; } - .tabs-color-stable .tab-item.tab-item-active .badge, .tabs-color-stable .tab-item.active .badge, .tabs-color-stable .tab-item.activated .badge { - opacity: 1; } - -.tabs-color-positive .tab-item { - color: rgba(56, 126, 245, 0.4); - opacity: 1; } - .tabs-color-positive .tab-item .badge { - opacity: 0.4; } - .tabs-color-positive .tab-item.tab-item-active, .tabs-color-positive .tab-item.active, .tabs-color-positive .tab-item.activated { - color: #387ef5; - border: 0 solid #387ef5; } - .tabs-color-positive .tab-item.tab-item-active .badge, .tabs-color-positive .tab-item.active .badge, .tabs-color-positive .tab-item.activated .badge { - opacity: 1; } - -.tabs-color-calm .tab-item { - color: rgba(17, 193, 243, 0.4); - opacity: 1; } - .tabs-color-calm .tab-item .badge { - opacity: 0.4; } - .tabs-color-calm .tab-item.tab-item-active, .tabs-color-calm .tab-item.active, .tabs-color-calm .tab-item.activated { - color: #11c1f3; - border: 0 solid #11c1f3; } - .tabs-color-calm .tab-item.tab-item-active .badge, .tabs-color-calm .tab-item.active .badge, .tabs-color-calm .tab-item.activated .badge { - opacity: 1; } - -.tabs-color-assertive .tab-item { - color: rgba(239, 71, 58, 0.4); - opacity: 1; } - .tabs-color-assertive .tab-item .badge { - opacity: 0.4; } - .tabs-color-assertive .tab-item.tab-item-active, .tabs-color-assertive .tab-item.active, .tabs-color-assertive .tab-item.activated { - color: #ef473a; - border: 0 solid #ef473a; } - .tabs-color-assertive .tab-item.tab-item-active .badge, .tabs-color-assertive .tab-item.active .badge, .tabs-color-assertive .tab-item.activated .badge { - opacity: 1; } - -.tabs-color-balanced .tab-item { - color: rgba(51, 205, 95, 0.4); - opacity: 1; } - .tabs-color-balanced .tab-item .badge { - opacity: 0.4; } - .tabs-color-balanced .tab-item.tab-item-active, .tabs-color-balanced .tab-item.active, .tabs-color-balanced .tab-item.activated { - color: #33cd5f; - border: 0 solid #33cd5f; } - .tabs-color-balanced .tab-item.tab-item-active .badge, .tabs-color-balanced .tab-item.active .badge, .tabs-color-balanced .tab-item.activated .badge { - opacity: 1; } - -.tabs-color-energized .tab-item { - color: rgba(255, 201, 0, 0.4); - opacity: 1; } - .tabs-color-energized .tab-item .badge { - opacity: 0.4; } - .tabs-color-energized .tab-item.tab-item-active, .tabs-color-energized .tab-item.active, .tabs-color-energized .tab-item.activated { - color: #ffc900; - border: 0 solid #ffc900; } - .tabs-color-energized .tab-item.tab-item-active .badge, .tabs-color-energized .tab-item.active .badge, .tabs-color-energized .tab-item.activated .badge { - opacity: 1; } - -.tabs-color-royal .tab-item { - color: rgba(136, 106, 234, 0.4); - opacity: 1; } - .tabs-color-royal .tab-item .badge { - opacity: 0.4; } - .tabs-color-royal .tab-item.tab-item-active, .tabs-color-royal .tab-item.active, .tabs-color-royal .tab-item.activated { - color: #886aea; - border: 0 solid #886aea; } - .tabs-color-royal .tab-item.tab-item-active .badge, .tabs-color-royal .tab-item.active .badge, .tabs-color-royal .tab-item.activated .badge { - opacity: 1; } - -.tabs-color-dark .tab-item { - color: rgba(68, 68, 68, 0.4); - opacity: 1; } - .tabs-color-dark .tab-item .badge { - opacity: 0.4; } - .tabs-color-dark .tab-item.tab-item-active, .tabs-color-dark .tab-item.active, .tabs-color-dark .tab-item.activated { - color: #444; - border: 0 solid #444; } - .tabs-color-dark .tab-item.tab-item-active .badge, .tabs-color-dark .tab-item.active .badge, .tabs-color-dark .tab-item.activated .badge { - opacity: 1; } - -ion-tabs.tabs-color-active-light .tab-item { - color: #444; } - ion-tabs.tabs-color-active-light .tab-item.tab-item-active, ion-tabs.tabs-color-active-light .tab-item.active, ion-tabs.tabs-color-active-light .tab-item.activated { - color: #fff; } - -ion-tabs.tabs-striped.tabs-color-active-light .tab-item.tab-item-active, ion-tabs.tabs-striped.tabs-color-active-light .tab-item.active, ion-tabs.tabs-striped.tabs-color-active-light .tab-item.activated { - border-color: #fff; - color: #fff; } - -ion-tabs.tabs-color-active-stable .tab-item { - color: #444; } - ion-tabs.tabs-color-active-stable .tab-item.tab-item-active, ion-tabs.tabs-color-active-stable .tab-item.active, ion-tabs.tabs-color-active-stable .tab-item.activated { - color: #f8f8f8; } - -ion-tabs.tabs-striped.tabs-color-active-stable .tab-item.tab-item-active, ion-tabs.tabs-striped.tabs-color-active-stable .tab-item.active, ion-tabs.tabs-striped.tabs-color-active-stable .tab-item.activated { - border-color: #f8f8f8; - color: #f8f8f8; } - -ion-tabs.tabs-color-active-positive .tab-item { - color: #444; } - ion-tabs.tabs-color-active-positive .tab-item.tab-item-active, ion-tabs.tabs-color-active-positive .tab-item.active, ion-tabs.tabs-color-active-positive .tab-item.activated { - color: #387ef5; } - -ion-tabs.tabs-striped.tabs-color-active-positive .tab-item.tab-item-active, ion-tabs.tabs-striped.tabs-color-active-positive .tab-item.active, ion-tabs.tabs-striped.tabs-color-active-positive .tab-item.activated { - border-color: #387ef5; - color: #387ef5; } - -ion-tabs.tabs-color-active-calm .tab-item { - color: #444; } - ion-tabs.tabs-color-active-calm .tab-item.tab-item-active, ion-tabs.tabs-color-active-calm .tab-item.active, ion-tabs.tabs-color-active-calm .tab-item.activated { - color: #11c1f3; } - -ion-tabs.tabs-striped.tabs-color-active-calm .tab-item.tab-item-active, ion-tabs.tabs-striped.tabs-color-active-calm .tab-item.active, ion-tabs.tabs-striped.tabs-color-active-calm .tab-item.activated { - border-color: #11c1f3; - color: #11c1f3; } - -ion-tabs.tabs-color-active-assertive .tab-item { - color: #444; } - ion-tabs.tabs-color-active-assertive .tab-item.tab-item-active, ion-tabs.tabs-color-active-assertive .tab-item.active, ion-tabs.tabs-color-active-assertive .tab-item.activated { - color: #ef473a; } - -ion-tabs.tabs-striped.tabs-color-active-assertive .tab-item.tab-item-active, ion-tabs.tabs-striped.tabs-color-active-assertive .tab-item.active, ion-tabs.tabs-striped.tabs-color-active-assertive .tab-item.activated { - border-color: #ef473a; - color: #ef473a; } - -ion-tabs.tabs-color-active-balanced .tab-item { - color: #444; } - ion-tabs.tabs-color-active-balanced .tab-item.tab-item-active, ion-tabs.tabs-color-active-balanced .tab-item.active, ion-tabs.tabs-color-active-balanced .tab-item.activated { - color: #33cd5f; } - -ion-tabs.tabs-striped.tabs-color-active-balanced .tab-item.tab-item-active, ion-tabs.tabs-striped.tabs-color-active-balanced .tab-item.active, ion-tabs.tabs-striped.tabs-color-active-balanced .tab-item.activated { - border-color: #33cd5f; - color: #33cd5f; } - -ion-tabs.tabs-color-active-energized .tab-item { - color: #444; } - ion-tabs.tabs-color-active-energized .tab-item.tab-item-active, ion-tabs.tabs-color-active-energized .tab-item.active, ion-tabs.tabs-color-active-energized .tab-item.activated { - color: #ffc900; } - -ion-tabs.tabs-striped.tabs-color-active-energized .tab-item.tab-item-active, ion-tabs.tabs-striped.tabs-color-active-energized .tab-item.active, ion-tabs.tabs-striped.tabs-color-active-energized .tab-item.activated { - border-color: #ffc900; - color: #ffc900; } - -ion-tabs.tabs-color-active-royal .tab-item { - color: #444; } - ion-tabs.tabs-color-active-royal .tab-item.tab-item-active, ion-tabs.tabs-color-active-royal .tab-item.active, ion-tabs.tabs-color-active-royal .tab-item.activated { - color: #886aea; } - -ion-tabs.tabs-striped.tabs-color-active-royal .tab-item.tab-item-active, ion-tabs.tabs-striped.tabs-color-active-royal .tab-item.active, ion-tabs.tabs-striped.tabs-color-active-royal .tab-item.activated { - border-color: #886aea; - color: #886aea; } - -ion-tabs.tabs-color-active-dark .tab-item { - color: #fff; } - ion-tabs.tabs-color-active-dark .tab-item.tab-item-active, ion-tabs.tabs-color-active-dark .tab-item.active, ion-tabs.tabs-color-active-dark .tab-item.activated { - color: #444; } - -ion-tabs.tabs-striped.tabs-color-active-dark .tab-item.tab-item-active, ion-tabs.tabs-striped.tabs-color-active-dark .tab-item.active, ion-tabs.tabs-striped.tabs-color-active-dark .tab-item.activated { - border-color: #444; - color: #444; } - -.tabs-top.tabs-striped { - padding-bottom: 0; } - .tabs-top.tabs-striped .tab-item { - background: transparent; - -webkit-transition: color .1s ease; - -moz-transition: color .1s ease; - -ms-transition: color .1s ease; - -o-transition: color .1s ease; - transition: color .1s ease; } - .tabs-top.tabs-striped .tab-item.tab-item-active, .tabs-top.tabs-striped .tab-item.active, .tabs-top.tabs-striped .tab-item.activated { - margin-top: 1px; - border-width: 0px 0px 2px 0px !important; - border-style: solid; } - .tabs-top.tabs-striped .tab-item.tab-item-active > .badge, .tabs-top.tabs-striped .tab-item.tab-item-active > i, .tabs-top.tabs-striped .tab-item.active > .badge, .tabs-top.tabs-striped .tab-item.active > i, .tabs-top.tabs-striped .tab-item.activated > .badge, .tabs-top.tabs-striped .tab-item.activated > i { - margin-top: -1px; } - .tabs-top.tabs-striped .tab-item .badge { - -webkit-transition: color .2s ease; - -moz-transition: color .2s ease; - -ms-transition: color .2s ease; - -o-transition: color .2s ease; - transition: color .2s ease; } - .tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.tab-item-active .tab-title, .tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.tab-item-active i, .tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.active .tab-title, .tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.active i, .tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.activated .tab-title, .tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.activated i { - display: block; - margin-top: -1px; } - .tabs-top.tabs-striped.tabs-icon-left .tab-item { - margin-top: 1px; } - .tabs-top.tabs-striped.tabs-icon-left .tab-item.tab-item-active .tab-title, .tabs-top.tabs-striped.tabs-icon-left .tab-item.tab-item-active i, .tabs-top.tabs-striped.tabs-icon-left .tab-item.active .tab-title, .tabs-top.tabs-striped.tabs-icon-left .tab-item.active i, .tabs-top.tabs-striped.tabs-icon-left .tab-item.activated .tab-title, .tabs-top.tabs-striped.tabs-icon-left .tab-item.activated i { - margin-top: -0.1em; } - -/* Allow parent element to have tabs-top */ -/* If you change this, change platform.scss as well */ -.tabs-top > .tabs, -.tabs.tabs-top { - top: 44px; - padding-top: 0; - background-position: bottom; - border-top-width: 0; - border-bottom-width: 1px; } - .tabs-top > .tabs .tab-item.tab-item-active .badge, .tabs-top > .tabs .tab-item.active .badge, .tabs-top > .tabs .tab-item.activated .badge, - .tabs.tabs-top .tab-item.tab-item-active .badge, - .tabs.tabs-top .tab-item.active .badge, - .tabs.tabs-top .tab-item.activated .badge { - top: 4%; } - -.tabs-top ~ .bar-header { - border-bottom-width: 0; } - -.tab-item { - -webkit-box-flex: 1; - -webkit-flex: 1; - -moz-box-flex: 1; - -moz-flex: 1; - -ms-flex: 1; - flex: 1; - display: block; - overflow: hidden; - max-width: 150px; - height: 100%; - color: inherit; - text-align: center; - text-decoration: none; - text-overflow: ellipsis; - white-space: nowrap; - font-weight: 400; - font-size: 14px; - font-family: "-apple-system", "Helvetica Neue", "Roboto", "Segoe UI", sans-serif; - opacity: 0.7; } - .tab-item:hover { - cursor: pointer; } - .tab-item.tab-hidden { - display: none; } - -.tabs-item-hide > .tabs, -.tabs.tabs-item-hide { - display: none; } - -.tabs-icon-top > .tabs .tab-item, -.tabs-icon-top.tabs .tab-item, -.tabs-icon-bottom > .tabs .tab-item, -.tabs-icon-bottom.tabs .tab-item { - font-size: 10px; - line-height: 14px; } - -.tab-item .icon { - display: block; - margin: 0 auto; - height: 32px; - font-size: 32px; } - -.tabs-icon-left.tabs .tab-item, -.tabs-icon-left > .tabs .tab-item, -.tabs-icon-right.tabs .tab-item, -.tabs-icon-right > .tabs .tab-item { - font-size: 10px; } - .tabs-icon-left.tabs .tab-item .icon, .tabs-icon-left.tabs .tab-item .tab-title, - .tabs-icon-left > .tabs .tab-item .icon, - .tabs-icon-left > .tabs .tab-item .tab-title, - .tabs-icon-right.tabs .tab-item .icon, - .tabs-icon-right.tabs .tab-item .tab-title, - .tabs-icon-right > .tabs .tab-item .icon, - .tabs-icon-right > .tabs .tab-item .tab-title { - display: inline-block; - vertical-align: top; - margin-top: -.1em; } - .tabs-icon-left.tabs .tab-item .icon:before, .tabs-icon-left.tabs .tab-item .tab-title:before, - .tabs-icon-left > .tabs .tab-item .icon:before, - .tabs-icon-left > .tabs .tab-item .tab-title:before, - .tabs-icon-right.tabs .tab-item .icon:before, - .tabs-icon-right.tabs .tab-item .tab-title:before, - .tabs-icon-right > .tabs .tab-item .icon:before, - .tabs-icon-right > .tabs .tab-item .tab-title:before { - font-size: 24px; - line-height: 49px; } - -.tabs-icon-left > .tabs .tab-item .icon, -.tabs-icon-left.tabs .tab-item .icon { - padding-right: 3px; } - -.tabs-icon-right > .tabs .tab-item .icon, -.tabs-icon-right.tabs .tab-item .icon { - padding-left: 3px; } - -.tabs-icon-only > .tabs .icon, -.tabs-icon-only.tabs .icon { - line-height: inherit; } - -.tab-item.has-badge { - position: relative; } - -.tab-item .badge { - position: absolute; - top: 4%; - right: 33%; - right: calc(50% - 26px); - padding: 1px 6px; - height: auto; - font-size: 12px; - line-height: 16px; } - -/* Navigational tab */ -/* Active state for tab */ -.tab-item.tab-item-active, -.tab-item.active, -.tab-item.activated { - opacity: 1; } - .tab-item.tab-item-active.tab-item-light, - .tab-item.active.tab-item-light, - .tab-item.activated.tab-item-light { - color: #fff; } - .tab-item.tab-item-active.tab-item-stable, - .tab-item.active.tab-item-stable, - .tab-item.activated.tab-item-stable { - color: #f8f8f8; } - .tab-item.tab-item-active.tab-item-positive, - .tab-item.active.tab-item-positive, - .tab-item.activated.tab-item-positive { - color: #387ef5; } - .tab-item.tab-item-active.tab-item-calm, - .tab-item.active.tab-item-calm, - .tab-item.activated.tab-item-calm { - color: #11c1f3; } - .tab-item.tab-item-active.tab-item-assertive, - .tab-item.active.tab-item-assertive, - .tab-item.activated.tab-item-assertive { - color: #ef473a; } - .tab-item.tab-item-active.tab-item-balanced, - .tab-item.active.tab-item-balanced, - .tab-item.activated.tab-item-balanced { - color: #33cd5f; } - .tab-item.tab-item-active.tab-item-energized, - .tab-item.active.tab-item-energized, - .tab-item.activated.tab-item-energized { - color: #ffc900; } - .tab-item.tab-item-active.tab-item-royal, - .tab-item.active.tab-item-royal, - .tab-item.activated.tab-item-royal { - color: #886aea; } - .tab-item.tab-item-active.tab-item-dark, - .tab-item.active.tab-item-dark, - .tab-item.activated.tab-item-dark { - color: #444; } - -.item.tabs { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - padding: 0; } - .item.tabs .icon:before { - position: relative; } - -.tab-item.disabled, -.tab-item[disabled] { - opacity: .4; - cursor: default; - pointer-events: none; } - -.nav-bar-tabs-top.hide ~ .view-container .tabs-top .tabs { - top: 0; } - -.pane[hide-nav-bar="true"] .has-tabs-top { - top: 49px; } - -/** - * Menus - * -------------------------------------------------- - * Side panel structure - */ -.menu { - position: absolute; - top: 0; - bottom: 0; - z-index: 0; - overflow: hidden; - min-height: 100%; - max-height: 100%; - width: 275px; - background-color: #fff; } - .menu .scroll-content { - z-index: 10; } - .menu .bar-header { - z-index: 11; } - -.menu-content { - -webkit-transform: none; - transform: none; - box-shadow: -1px 0px 2px rgba(0, 0, 0, 0.2), 1px 0px 2px rgba(0, 0, 0, 0.2); } - -.menu-open .menu-content .pane, -.menu-open .menu-content .scroll-content { - pointer-events: none; } - -.menu-open .menu-content .scroll-content .scroll { - pointer-events: none; } - -.menu-open .menu-content .scroll-content:not(.overflow-scroll) { - overflow: hidden; } - -.grade-b .menu-content, -.grade-c .menu-content { - -webkit-box-sizing: content-box; - -moz-box-sizing: content-box; - box-sizing: content-box; - right: -1px; - left: -1px; - border-right: 1px solid #ccc; - border-left: 1px solid #ccc; - box-shadow: none; } - -.menu-left { - left: 0; } - -.menu-right { - right: 0; } - -.aside-open.aside-resizing .menu-right { - display: none; } - -.menu-animated { - -webkit-transition: -webkit-transform 200ms ease; - transition: transform 200ms ease; } - -/** - * Modals - * -------------------------------------------------- - * Modals are independent windows that slide in from off-screen. - */ -.modal-backdrop, -.modal-backdrop-bg { - position: fixed; - top: 0; - left: 0; - z-index: 10; - width: 100%; - height: 100%; } - -.modal-backdrop-bg { - pointer-events: none; } - -.modal { - display: block; - position: absolute; - top: 0; - z-index: 10; - overflow: hidden; - min-height: 100%; - width: 100%; - background-color: #fff; } - -@media (min-width: 680px) { - .modal { - top: 20%; - right: 20%; - bottom: 20%; - left: 20%; - min-height: 240px; - width: 60%; } - .modal.ng-leave-active { - bottom: 0; } - .platform-ios.platform-cordova .modal-wrapper .modal .bar-header:not(.bar-subheader) { - height: 44px; } - .platform-ios.platform-cordova .modal-wrapper .modal .bar-header:not(.bar-subheader) > * { - margin-top: 0; } - .platform-ios.platform-cordova .modal-wrapper .modal .tabs-top > .tabs, - .platform-ios.platform-cordova .modal-wrapper .modal .tabs.tabs-top { - top: 44px; } - .platform-ios.platform-cordova .modal-wrapper .modal .has-header, - .platform-ios.platform-cordova .modal-wrapper .modal .bar-subheader { - top: 44px; } - .platform-ios.platform-cordova .modal-wrapper .modal .has-subheader { - top: 88px; } - .platform-ios.platform-cordova .modal-wrapper .modal .has-header.has-tabs-top { - top: 93px; } - .platform-ios.platform-cordova .modal-wrapper .modal .has-header.has-subheader.has-tabs-top { - top: 137px; } - .modal-backdrop-bg { - -webkit-transition: opacity 300ms ease-in-out; - transition: opacity 300ms ease-in-out; - background-color: #000; - opacity: 0; } - .active .modal-backdrop-bg { - opacity: 0.5; } } - -.modal-open { - pointer-events: none; } - .modal-open .modal, - .modal-open .modal-backdrop { - pointer-events: auto; } - .modal-open.loading-active .modal, - .modal-open.loading-active .modal-backdrop { - pointer-events: none; } - -/** - * Popovers - * -------------------------------------------------- - * Popovers are independent views which float over content - */ -.popover-backdrop { - position: fixed; - top: 0; - left: 0; - z-index: 10; - width: 100%; - height: 100%; - background-color: transparent; } - .popover-backdrop.active { - background-color: rgba(0, 0, 0, 0.1); } - -.popover { - position: absolute; - top: 25%; - left: 50%; - z-index: 10; - display: block; - margin-top: 12px; - margin-left: -110px; - height: 280px; - width: 220px; - background-color: #fff; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); - opacity: 0; } - .popover .item:first-child { - border-top: 0; } - .popover .item:last-child { - border-bottom: 0; } - .popover.popover-bottom { - margin-top: -12px; } - -.popover, -.popover .bar-header { - border-radius: 2px; } - -.popover .scroll-content { - z-index: 1; - margin: 2px 0; } - -.popover .bar-header { - border-bottom-right-radius: 0; - border-bottom-left-radius: 0; } - -.popover .has-header { - border-top-right-radius: 0; - border-top-left-radius: 0; } - -.popover-arrow { - display: none; } - -.platform-ios .popover { - box-shadow: 0 0 40px rgba(0, 0, 0, 0.08); - border-radius: 10px; } - -.platform-ios .popover .bar-header { - -webkit-border-top-right-radius: 10px; - border-top-right-radius: 10px; - -webkit-border-top-left-radius: 10px; - border-top-left-radius: 10px; } - -.platform-ios .popover .scroll-content { - margin: 8px 0; - border-radius: 10px; } - -.platform-ios .popover .scroll-content.has-header { - margin-top: 0; } - -.platform-ios .popover-arrow { - position: absolute; - display: block; - top: -17px; - width: 30px; - height: 19px; - overflow: hidden; } - .platform-ios .popover-arrow:after { - position: absolute; - top: 12px; - left: 5px; - width: 20px; - height: 20px; - background-color: #fff; - border-radius: 3px; - content: ''; - -webkit-transform: rotate(-45deg); - transform: rotate(-45deg); } - -.platform-ios .popover-bottom .popover-arrow { - top: auto; - bottom: -10px; } - .platform-ios .popover-bottom .popover-arrow:after { - top: -6px; } - -.platform-android .popover { - margin-top: -32px; - background-color: #fafafa; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35); } - .platform-android .popover .item { - border-color: #fafafa; - background-color: #fafafa; - color: #4d4d4d; } - .platform-android .popover.popover-bottom { - margin-top: 32px; } - -.platform-android .popover-backdrop, -.platform-android .popover-backdrop.active { - background-color: transparent; } - -.popover-open { - pointer-events: none; } - .popover-open .popover, - .popover-open .popover-backdrop { - pointer-events: auto; } - .popover-open.loading-active .popover, - .popover-open.loading-active .popover-backdrop { - pointer-events: none; } - -@media (min-width: 680px) { - .popover { - width: 360px; - margin-left: -180px; } } - -/** - * Popups - * -------------------------------------------------- - */ -.popup-container { - position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; - background: transparent; - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-pack: center; - -ms-flex-pack: center; - -webkit-justify-content: center; - -moz-justify-content: center; - justify-content: center; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - z-index: 12; - visibility: hidden; } - .popup-container.popup-showing { - visibility: visible; } - .popup-container.popup-hidden .popup { - -webkit-animation-name: scaleOut; - animation-name: scaleOut; - -webkit-animation-duration: 0.1s; - animation-duration: 0.1s; - -webkit-animation-timing-function: ease-in-out; - animation-timing-function: ease-in-out; - -webkit-animation-fill-mode: both; - animation-fill-mode: both; } - .popup-container.active .popup { - -webkit-animation-name: superScaleIn; - animation-name: superScaleIn; - -webkit-animation-duration: 0.2s; - animation-duration: 0.2s; - -webkit-animation-timing-function: ease-in-out; - animation-timing-function: ease-in-out; - -webkit-animation-fill-mode: both; - animation-fill-mode: both; } - .popup-container .popup { - width: 250px; - max-width: 100%; - max-height: 90%; - border-radius: 0px; - background-color: rgba(255, 255, 255, 0.9); - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-direction: normal; - -webkit-box-orient: vertical; - -webkit-flex-direction: column; - -moz-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; } - .popup-container input, - .popup-container textarea { - width: 100%; } - -.popup-head { - padding: 15px 10px; - border-bottom: 1px solid #eee; - text-align: center; } - -.popup-title { - margin: 0; - padding: 0; - font-size: 15px; } - -.popup-sub-title { - margin: 5px 0 0 0; - padding: 0; - font-weight: normal; - font-size: 11px; } - -.popup-body { - padding: 10px; - overflow: auto; } - -.popup-buttons { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-direction: normal; - -webkit-box-orient: horizontal; - -webkit-flex-direction: row; - -moz-flex-direction: row; - -ms-flex-direction: row; - flex-direction: row; - padding: 10px; - min-height: 65px; } - .popup-buttons .button { - -webkit-box-flex: 1; - -webkit-flex: 1; - -moz-box-flex: 1; - -moz-flex: 1; - -ms-flex: 1; - flex: 1; - display: block; - min-height: 45px; - border-radius: 2px; - line-height: 20px; - margin-right: 5px; } - .popup-buttons .button:last-child { - margin-right: 0px; } - -.popup-open { - pointer-events: none; } - .popup-open.modal-open .modal { - pointer-events: none; } - .popup-open .popup-backdrop, .popup-open .popup { - pointer-events: auto; } - -/** - * Loading - * -------------------------------------------------- - */ -.loading-container { - position: absolute; - left: 0; - top: 0; - right: 0; - bottom: 0; - z-index: 13; - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-pack: center; - -ms-flex-pack: center; - -webkit-justify-content: center; - -moz-justify-content: center; - justify-content: center; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - -webkit-transition: 0.2s opacity linear; - transition: 0.2s opacity linear; - visibility: hidden; - opacity: 0; } - .loading-container:not(.visible) .icon, - .loading-container:not(.visible) .spinner { - display: none; } - .loading-container.visible { - visibility: visible; } - .loading-container.active { - opacity: 1; } - .loading-container .loading { - padding: 20px; - border-radius: 5px; - background-color: rgba(0, 0, 0, 0.7); - color: #fff; - text-align: center; - text-overflow: ellipsis; - font-size: 15px; } - .loading-container .loading h1, .loading-container .loading h2, .loading-container .loading h3, .loading-container .loading h4, .loading-container .loading h5, .loading-container .loading h6 { - color: #fff; } - -/** - * Items - * -------------------------------------------------- - */ -.item { - border-color: #ddd; - background-color: #fff; - color: #444; - position: relative; - z-index: 2; - display: block; - margin: -1px; - padding: 16px; - border-width: 1px; - border-style: solid; - font-size: 16px; } - .item h2 { - margin: 0 0 2px 0; - font-size: 16px; - font-weight: normal; } - .item h3 { - margin: 0 0 4px 0; - font-size: 14px; } - .item h4 { - margin: 0 0 4px 0; - font-size: 12px; } - .item h5, .item h6 { - margin: 0 0 3px 0; - font-size: 10px; } - .item p { - color: #666; - font-size: 14px; - margin-bottom: 2px; } - .item h1:last-child, - .item h2:last-child, - .item h3:last-child, - .item h4:last-child, - .item h5:last-child, - .item h6:last-child, - .item p:last-child { - margin-bottom: 0; } - .item .badge { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - position: absolute; - top: 16px; - right: 32px; } - .item.item-button-right .badge { - right: 67px; } - .item.item-divider .badge { - top: 8px; } - .item .badge + .badge { - margin-right: 5px; } - .item.item-light { - border-color: #ddd; - background-color: #fff; - color: #444; } - .item.item-stable { - border-color: #b2b2b2; - background-color: #f8f8f8; - color: #444; } - .item.item-positive { - border-color: #0c60ee; - background-color: #387ef5; - color: #fff; } - .item.item-calm { - border-color: #0a9dc7; - background-color: #11c1f3; - color: #fff; } - .item.item-assertive { - border-color: #e42112; - background-color: #ef473a; - color: #fff; } - .item.item-balanced { - border-color: #28a54c; - background-color: #33cd5f; - color: #fff; } - .item.item-energized { - border-color: #e6b500; - background-color: #ffc900; - color: #fff; } - .item.item-royal { - border-color: #6b46e5; - background-color: #886aea; - color: #fff; } - .item.item-dark { - border-color: #111; - background-color: #444; - color: #fff; } - .item[ng-click]:hover { - cursor: pointer; } - -.list-borderless .item, -.item-borderless { - border-width: 0; } - -.item.active, -.item.activated, -.item-complex.active .item-content, -.item-complex.activated .item-content, -.item .item-content.active, -.item .item-content.activated { - border-color: #ccc; - background-color: #D9D9D9; } - .item.active.item-complex > .item-content, - .item.activated.item-complex > .item-content, - .item-complex.active .item-content.item-complex > .item-content, - .item-complex.activated .item-content.item-complex > .item-content, - .item .item-content.active.item-complex > .item-content, - .item .item-content.activated.item-complex > .item-content { - border-color: #ccc; - background-color: #D9D9D9; } - .item.active.item-light, - .item.activated.item-light, - .item-complex.active .item-content.item-light, - .item-complex.activated .item-content.item-light, - .item .item-content.active.item-light, - .item .item-content.activated.item-light { - border-color: #ccc; - background-color: #fafafa; } - .item.active.item-light.item-complex > .item-content, - .item.activated.item-light.item-complex > .item-content, - .item-complex.active .item-content.item-light.item-complex > .item-content, - .item-complex.activated .item-content.item-light.item-complex > .item-content, - .item .item-content.active.item-light.item-complex > .item-content, - .item .item-content.activated.item-light.item-complex > .item-content { - border-color: #ccc; - background-color: #fafafa; } - .item.active.item-stable, - .item.activated.item-stable, - .item-complex.active .item-content.item-stable, - .item-complex.activated .item-content.item-stable, - .item .item-content.active.item-stable, - .item .item-content.activated.item-stable { - border-color: #a2a2a2; - background-color: #e5e5e5; } - .item.active.item-stable.item-complex > .item-content, - .item.activated.item-stable.item-complex > .item-content, - .item-complex.active .item-content.item-stable.item-complex > .item-content, - .item-complex.activated .item-content.item-stable.item-complex > .item-content, - .item .item-content.active.item-stable.item-complex > .item-content, - .item .item-content.activated.item-stable.item-complex > .item-content { - border-color: #a2a2a2; - background-color: #e5e5e5; } - .item.active.item-positive, - .item.activated.item-positive, - .item-complex.active .item-content.item-positive, - .item-complex.activated .item-content.item-positive, - .item .item-content.active.item-positive, - .item .item-content.activated.item-positive { - border-color: #0c60ee; - background-color: #0c60ee; } - .item.active.item-positive.item-complex > .item-content, - .item.activated.item-positive.item-complex > .item-content, - .item-complex.active .item-content.item-positive.item-complex > .item-content, - .item-complex.activated .item-content.item-positive.item-complex > .item-content, - .item .item-content.active.item-positive.item-complex > .item-content, - .item .item-content.activated.item-positive.item-complex > .item-content { - border-color: #0c60ee; - background-color: #0c60ee; } - .item.active.item-calm, - .item.activated.item-calm, - .item-complex.active .item-content.item-calm, - .item-complex.activated .item-content.item-calm, - .item .item-content.active.item-calm, - .item .item-content.activated.item-calm { - border-color: #0a9dc7; - background-color: #0a9dc7; } - .item.active.item-calm.item-complex > .item-content, - .item.activated.item-calm.item-complex > .item-content, - .item-complex.active .item-content.item-calm.item-complex > .item-content, - .item-complex.activated .item-content.item-calm.item-complex > .item-content, - .item .item-content.active.item-calm.item-complex > .item-content, - .item .item-content.activated.item-calm.item-complex > .item-content { - border-color: #0a9dc7; - background-color: #0a9dc7; } - .item.active.item-assertive, - .item.activated.item-assertive, - .item-complex.active .item-content.item-assertive, - .item-complex.activated .item-content.item-assertive, - .item .item-content.active.item-assertive, - .item .item-content.activated.item-assertive { - border-color: #e42112; - background-color: #e42112; } - .item.active.item-assertive.item-complex > .item-content, - .item.activated.item-assertive.item-complex > .item-content, - .item-complex.active .item-content.item-assertive.item-complex > .item-content, - .item-complex.activated .item-content.item-assertive.item-complex > .item-content, - .item .item-content.active.item-assertive.item-complex > .item-content, - .item .item-content.activated.item-assertive.item-complex > .item-content { - border-color: #e42112; - background-color: #e42112; } - .item.active.item-balanced, - .item.activated.item-balanced, - .item-complex.active .item-content.item-balanced, - .item-complex.activated .item-content.item-balanced, - .item .item-content.active.item-balanced, - .item .item-content.activated.item-balanced { - border-color: #28a54c; - background-color: #28a54c; } - .item.active.item-balanced.item-complex > .item-content, - .item.activated.item-balanced.item-complex > .item-content, - .item-complex.active .item-content.item-balanced.item-complex > .item-content, - .item-complex.activated .item-content.item-balanced.item-complex > .item-content, - .item .item-content.active.item-balanced.item-complex > .item-content, - .item .item-content.activated.item-balanced.item-complex > .item-content { - border-color: #28a54c; - background-color: #28a54c; } - .item.active.item-energized, - .item.activated.item-energized, - .item-complex.active .item-content.item-energized, - .item-complex.activated .item-content.item-energized, - .item .item-content.active.item-energized, - .item .item-content.activated.item-energized { - border-color: #e6b500; - background-color: #e6b500; } - .item.active.item-energized.item-complex > .item-content, - .item.activated.item-energized.item-complex > .item-content, - .item-complex.active .item-content.item-energized.item-complex > .item-content, - .item-complex.activated .item-content.item-energized.item-complex > .item-content, - .item .item-content.active.item-energized.item-complex > .item-content, - .item .item-content.activated.item-energized.item-complex > .item-content { - border-color: #e6b500; - background-color: #e6b500; } - .item.active.item-royal, - .item.activated.item-royal, - .item-complex.active .item-content.item-royal, - .item-complex.activated .item-content.item-royal, - .item .item-content.active.item-royal, - .item .item-content.activated.item-royal { - border-color: #6b46e5; - background-color: #6b46e5; } - .item.active.item-royal.item-complex > .item-content, - .item.activated.item-royal.item-complex > .item-content, - .item-complex.active .item-content.item-royal.item-complex > .item-content, - .item-complex.activated .item-content.item-royal.item-complex > .item-content, - .item .item-content.active.item-royal.item-complex > .item-content, - .item .item-content.activated.item-royal.item-complex > .item-content { - border-color: #6b46e5; - background-color: #6b46e5; } - .item.active.item-dark, - .item.activated.item-dark, - .item-complex.active .item-content.item-dark, - .item-complex.activated .item-content.item-dark, - .item .item-content.active.item-dark, - .item .item-content.activated.item-dark { - border-color: #000; - background-color: #262626; } - .item.active.item-dark.item-complex > .item-content, - .item.activated.item-dark.item-complex > .item-content, - .item-complex.active .item-content.item-dark.item-complex > .item-content, - .item-complex.activated .item-content.item-dark.item-complex > .item-content, - .item .item-content.active.item-dark.item-complex > .item-content, - .item .item-content.activated.item-dark.item-complex > .item-content { - border-color: #000; - background-color: #262626; } - -.item, -.item h1, -.item h2, -.item h3, -.item h4, -.item h5, -.item h6, -.item p, -.item-content, -.item-content h1, -.item-content h2, -.item-content h3, -.item-content h4, -.item-content h5, -.item-content h6, -.item-content p { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } - -a.item { - color: inherit; - text-decoration: none; } - a.item:hover, a.item:focus { - text-decoration: none; } - -/** - * Complex Items - * -------------------------------------------------- - * Adding .item-complex allows the .item to be slidable and - * have options underneath the button, but also requires an - * additional .item-content element inside .item. - * Basically .item-complex removes any default settings which - * .item added, so that .item-content looks them as just .item. - */ -.item-complex, -a.item.item-complex, -button.item.item-complex { - padding: 0; } - -.item-complex .item-content, -.item-radio .item-content { - position: relative; - z-index: 2; - padding: 16px 49px 16px 16px; - border: none; - background-color: #fff; } - -a.item-content { - display: block; - color: inherit; - text-decoration: none; } - -.item-text-wrap .item, -.item-text-wrap .item-content, -.item-text-wrap, -.item-text-wrap h1, -.item-text-wrap h2, -.item-text-wrap h3, -.item-text-wrap h4, -.item-text-wrap h5, -.item-text-wrap h6, -.item-text-wrap p, -.item-complex.item-text-wrap .item-content, -.item-body h1, -.item-body h2, -.item-body h3, -.item-body h4, -.item-body h5, -.item-body h6, -.item-body p { - overflow: visible; - white-space: normal; } - -.item-complex.item-text-wrap, -.item-complex.item-text-wrap h1, -.item-complex.item-text-wrap h2, -.item-complex.item-text-wrap h3, -.item-complex.item-text-wrap h4, -.item-complex.item-text-wrap h5, -.item-complex.item-text-wrap h6, -.item-complex.item-text-wrap p { - overflow: visible; - white-space: normal; } - -.item-complex.item-light > .item-content { - border-color: #ddd; - background-color: #fff; - color: #444; } - .item-complex.item-light > .item-content.active, .item-complex.item-light > .item-content:active { - border-color: #ccc; - background-color: #fafafa; } - .item-complex.item-light > .item-content.active.item-complex > .item-content, .item-complex.item-light > .item-content:active.item-complex > .item-content { - border-color: #ccc; - background-color: #fafafa; } - -.item-complex.item-stable > .item-content { - border-color: #b2b2b2; - background-color: #f8f8f8; - color: #444; } - .item-complex.item-stable > .item-content.active, .item-complex.item-stable > .item-content:active { - border-color: #a2a2a2; - background-color: #e5e5e5; } - .item-complex.item-stable > .item-content.active.item-complex > .item-content, .item-complex.item-stable > .item-content:active.item-complex > .item-content { - border-color: #a2a2a2; - background-color: #e5e5e5; } - -.item-complex.item-positive > .item-content { - border-color: #0c60ee; - background-color: #387ef5; - color: #fff; } - .item-complex.item-positive > .item-content.active, .item-complex.item-positive > .item-content:active { - border-color: #0c60ee; - background-color: #0c60ee; } - .item-complex.item-positive > .item-content.active.item-complex > .item-content, .item-complex.item-positive > .item-content:active.item-complex > .item-content { - border-color: #0c60ee; - background-color: #0c60ee; } - -.item-complex.item-calm > .item-content { - border-color: #0a9dc7; - background-color: #11c1f3; - color: #fff; } - .item-complex.item-calm > .item-content.active, .item-complex.item-calm > .item-content:active { - border-color: #0a9dc7; - background-color: #0a9dc7; } - .item-complex.item-calm > .item-content.active.item-complex > .item-content, .item-complex.item-calm > .item-content:active.item-complex > .item-content { - border-color: #0a9dc7; - background-color: #0a9dc7; } - -.item-complex.item-assertive > .item-content { - border-color: #e42112; - background-color: #ef473a; - color: #fff; } - .item-complex.item-assertive > .item-content.active, .item-complex.item-assertive > .item-content:active { - border-color: #e42112; - background-color: #e42112; } - .item-complex.item-assertive > .item-content.active.item-complex > .item-content, .item-complex.item-assertive > .item-content:active.item-complex > .item-content { - border-color: #e42112; - background-color: #e42112; } - -.item-complex.item-balanced > .item-content { - border-color: #28a54c; - background-color: #33cd5f; - color: #fff; } - .item-complex.item-balanced > .item-content.active, .item-complex.item-balanced > .item-content:active { - border-color: #28a54c; - background-color: #28a54c; } - .item-complex.item-balanced > .item-content.active.item-complex > .item-content, .item-complex.item-balanced > .item-content:active.item-complex > .item-content { - border-color: #28a54c; - background-color: #28a54c; } - -.item-complex.item-energized > .item-content { - border-color: #e6b500; - background-color: #ffc900; - color: #fff; } - .item-complex.item-energized > .item-content.active, .item-complex.item-energized > .item-content:active { - border-color: #e6b500; - background-color: #e6b500; } - .item-complex.item-energized > .item-content.active.item-complex > .item-content, .item-complex.item-energized > .item-content:active.item-complex > .item-content { - border-color: #e6b500; - background-color: #e6b500; } - -.item-complex.item-royal > .item-content { - border-color: #6b46e5; - background-color: #886aea; - color: #fff; } - .item-complex.item-royal > .item-content.active, .item-complex.item-royal > .item-content:active { - border-color: #6b46e5; - background-color: #6b46e5; } - .item-complex.item-royal > .item-content.active.item-complex > .item-content, .item-complex.item-royal > .item-content:active.item-complex > .item-content { - border-color: #6b46e5; - background-color: #6b46e5; } - -.item-complex.item-dark > .item-content { - border-color: #111; - background-color: #444; - color: #fff; } - .item-complex.item-dark > .item-content.active, .item-complex.item-dark > .item-content:active { - border-color: #000; - background-color: #262626; } - .item-complex.item-dark > .item-content.active.item-complex > .item-content, .item-complex.item-dark > .item-content:active.item-complex > .item-content { - border-color: #000; - background-color: #262626; } - -/** - * Item Icons - * -------------------------------------------------- - */ -.item-icon-left .icon, -.item-icon-right .icon { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - position: absolute; - top: 0; - height: 100%; - font-size: 32px; } - .item-icon-left .icon:before, - .item-icon-right .icon:before { - display: block; - width: 32px; - text-align: center; } - -.item .fill-icon { - min-width: 30px; - min-height: 30px; - font-size: 28px; } - -.item-icon-left { - padding-left: 54px; } - .item-icon-left .icon { - left: 11px; } - -.item-complex.item-icon-left { - padding-left: 0; } - .item-complex.item-icon-left .item-content { - padding-left: 54px; } - -.item-icon-right { - padding-right: 54px; } - .item-icon-right .icon { - right: 11px; } - -.item-complex.item-icon-right { - padding-right: 0; } - .item-complex.item-icon-right .item-content { - padding-right: 54px; } - -.item-icon-left.item-icon-right .icon:first-child { - right: auto; } - -.item-icon-left.item-icon-right .icon:last-child, -.item-icon-left .item-delete .icon { - left: auto; } - -.item-icon-left .icon-accessory, -.item-icon-right .icon-accessory { - color: #ccc; - font-size: 16px; } - -.item-icon-left .icon-accessory { - left: 3px; } - -.item-icon-right .icon-accessory { - right: 3px; } - -/** - * Item Button - * -------------------------------------------------- - * An item button is a child button inside an .item (not the entire .item) - */ -.item-button-left { - padding-left: 72px; } - -.item-button-left > .button, -.item-button-left .item-content > .button { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - position: absolute; - top: 8px; - left: 11px; - min-width: 34px; - min-height: 34px; - font-size: 18px; - line-height: 32px; } - .item-button-left > .button .icon:before, - .item-button-left .item-content > .button .icon:before { - position: relative; - left: auto; - width: auto; - line-height: 31px; } - .item-button-left > .button > .button, - .item-button-left .item-content > .button > .button { - margin: 0px 2px; - min-height: 34px; - font-size: 18px; - line-height: 32px; } - -.item-button-right, -a.item.item-button-right, -button.item.item-button-right { - padding-right: 80px; } - -.item-button-right > .button, -.item-button-right .item-content > .button, -.item-button-right > .buttons, -.item-button-right .item-content > .buttons { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - position: absolute; - top: 8px; - right: 16px; - min-width: 34px; - min-height: 34px; - font-size: 18px; - line-height: 32px; } - .item-button-right > .button .icon:before, - .item-button-right .item-content > .button .icon:before, - .item-button-right > .buttons .icon:before, - .item-button-right .item-content > .buttons .icon:before { - position: relative; - left: auto; - width: auto; - line-height: 31px; } - .item-button-right > .button > .button, - .item-button-right .item-content > .button > .button, - .item-button-right > .buttons > .button, - .item-button-right .item-content > .buttons > .button { - margin: 0px 2px; - min-width: 34px; - min-height: 34px; - font-size: 18px; - line-height: 32px; } - -.item-button-left.item-button-right .button:first-child { - right: auto; } - -.item-button-left.item-button-right .button:last-child { - left: auto; } - -.item-avatar, -.item-avatar .item-content, -.item-avatar-left, -.item-avatar-left .item-content { - padding-left: 72px; - min-height: 72px; } - .item-avatar > img:first-child, - .item-avatar .item-image, - .item-avatar .item-content > img:first-child, - .item-avatar .item-content .item-image, - .item-avatar-left > img:first-child, - .item-avatar-left .item-image, - .item-avatar-left .item-content > img:first-child, - .item-avatar-left .item-content .item-image { - position: absolute; - top: 16px; - left: 16px; - max-width: 40px; - max-height: 40px; - width: 100%; - height: 100%; - border-radius: 50%; } - -.item-avatar-right, -.item-avatar-right .item-content { - padding-right: 72px; - min-height: 72px; } - .item-avatar-right > img:first-child, - .item-avatar-right .item-image, - .item-avatar-right .item-content > img:first-child, - .item-avatar-right .item-content .item-image { - position: absolute; - top: 16px; - right: 16px; - max-width: 40px; - max-height: 40px; - width: 100%; - height: 100%; - border-radius: 50%; } - -.item-thumbnail-left, -.item-thumbnail-left .item-content { - padding-top: 8px; - padding-left: 106px; - min-height: 100px; } - .item-thumbnail-left > img:first-child, - .item-thumbnail-left .item-image, - .item-thumbnail-left .item-content > img:first-child, - .item-thumbnail-left .item-content .item-image { - position: absolute; - top: 10px; - left: 10px; - max-width: 80px; - max-height: 80px; - width: 100%; - height: 100%; } - -.item-avatar.item-complex, -.item-avatar-left.item-complex, -.item-thumbnail-left.item-complex { - padding-top: 0; - padding-left: 0; } - -.item-thumbnail-right, -.item-thumbnail-right .item-content { - padding-top: 8px; - padding-right: 106px; - min-height: 100px; } - .item-thumbnail-right > img:first-child, - .item-thumbnail-right .item-image, - .item-thumbnail-right .item-content > img:first-child, - .item-thumbnail-right .item-content .item-image { - position: absolute; - top: 10px; - right: 10px; - max-width: 80px; - max-height: 80px; - width: 100%; - height: 100%; } - -.item-avatar-right.item-complex, -.item-thumbnail-right.item-complex { - padding-top: 0; - padding-right: 0; } - -.item-image { - padding: 0; - text-align: center; } - .item-image img:first-child, .item-image .list-img { - width: 100%; - vertical-align: middle; } - -.item-body { - overflow: auto; - padding: 16px; - text-overflow: inherit; - white-space: normal; } - .item-body h1, .item-body h2, .item-body h3, .item-body h4, .item-body h5, .item-body h6, .item-body p { - margin-top: 16px; - margin-bottom: 16px; } - -.item-divider { - padding-top: 8px; - padding-bottom: 8px; - min-height: 30px; - background-color: #f5f5f5; - color: #222; - font-weight: 500; } - -.platform-ios .item-divider-platform, -.item-divider-ios { - padding-top: 26px; - text-transform: uppercase; - font-weight: 300; - font-size: 13px; - background-color: #efeff4; - color: #555; } - -.platform-android .item-divider-platform, -.item-divider-android { - font-weight: 300; - font-size: 13px; } - -.item-note { - float: right; - color: #aaa; - font-size: 14px; } - -.item-left-editable .item-content, -.item-right-editable .item-content { - -webkit-transition-duration: 250ms; - transition-duration: 250ms; - -webkit-transition-timing-function: ease-in-out; - transition-timing-function: ease-in-out; - -webkit-transition-property: -webkit-transform; - -moz-transition-property: -moz-transform; - transition-property: transform; } - -.list-left-editing .item-left-editable .item-content, -.item-left-editing.item-left-editable .item-content { - -webkit-transform: translate3d(50px, 0, 0); - transform: translate3d(50px, 0, 0); } - -.item-remove-animate.ng-leave { - -webkit-transition-duration: 300ms; - transition-duration: 300ms; } - -.item-remove-animate.ng-leave .item-content, .item-remove-animate.ng-leave:last-of-type { - -webkit-transition-duration: 300ms; - transition-duration: 300ms; - -webkit-transition-timing-function: ease-in; - transition-timing-function: ease-in; - -webkit-transition-property: all; - transition-property: all; } - -.item-remove-animate.ng-leave.ng-leave-active .item-content { - opacity: 0; - -webkit-transform: translate3d(-100%, 0, 0) !important; - transform: translate3d(-100%, 0, 0) !important; } - -.item-remove-animate.ng-leave.ng-leave-active:last-of-type { - opacity: 0; } - -.item-remove-animate.ng-leave.ng-leave-active ~ ion-item:not(.ng-leave) { - -webkit-transform: translate3d(0, -webkit-calc(-100% + 1px), 0); - transform: translate3d(0, calc(-100% + 1px), 0); - -webkit-transition-duration: 300ms; - transition-duration: 300ms; - -webkit-transition-timing-function: cubic-bezier(0.25, 0.81, 0.24, 1); - transition-timing-function: cubic-bezier(0.25, 0.81, 0.24, 1); - -webkit-transition-property: all; - transition-property: all; } - -.item-left-edit { - -webkit-transition: all ease-in-out 125ms; - transition: all ease-in-out 125ms; - position: absolute; - top: 0; - left: 0; - z-index: 0; - width: 50px; - height: 100%; - line-height: 100%; - display: none; - opacity: 0; - -webkit-transform: translate3d(-21px, 0, 0); - transform: translate3d(-21px, 0, 0); } - .item-left-edit .button { - height: 100%; } - .item-left-edit .button.icon { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - position: absolute; - top: 0; - height: 100%; } - .item-left-edit.visible { - display: block; } - .item-left-edit.visible.active { - opacity: 1; - -webkit-transform: translate3d(8px, 0, 0); - transform: translate3d(8px, 0, 0); } - -.list-left-editing .item-left-edit { - -webkit-transition-delay: 125ms; - transition-delay: 125ms; } - -.item-delete .button.icon { - color: #ef473a; - font-size: 24px; } - .item-delete .button.icon:hover { - opacity: .7; } - -.item-right-edit { - -webkit-transition: all ease-in-out 250ms; - transition: all ease-in-out 250ms; - position: absolute; - top: 0; - right: 0; - z-index: 3; - width: 75px; - height: 100%; - background: inherit; - padding-left: 20px; - display: block; - opacity: 0; - -webkit-transform: translate3d(75px, 0, 0); - transform: translate3d(75px, 0, 0); } - .item-right-edit .button { - min-width: 50px; - height: 100%; } - .item-right-edit .button.icon { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - position: absolute; - top: 0; - height: 100%; - font-size: 32px; } - .item-right-edit.visible { - display: block; } - .item-right-edit.visible.active { - opacity: 1; - -webkit-transform: translate3d(0, 0, 0); - transform: translate3d(0, 0, 0); } - -.item-reorder .button.icon { - color: #444; - font-size: 32px; } - -.item-reordering { - position: absolute; - left: 0; - top: 0; - z-index: 9; - width: 100%; - box-shadow: 0px 0px 10px 0px #aaa; } - .item-reordering .item-reorder { - z-index: 9; } - -.item-placeholder { - opacity: 0.7; } - -/** - * The hidden right-side buttons that can be exposed under a list item - * with dragging. - */ -.item-options { - position: absolute; - top: 0; - right: 0; - z-index: 1; - height: 100%; } - .item-options .button { - height: 100%; - border: none; - border-radius: 0; - display: -webkit-inline-box; - display: -webkit-inline-flex; - display: -moz-inline-flex; - display: -ms-inline-flexbox; - display: inline-flex; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; } - .item-options .button:before { - margin: 0 auto; } - -/** - * Lists - * -------------------------------------------------- - */ -.list { - position: relative; - padding-top: 1px; - padding-bottom: 1px; - padding-left: 0; - margin-bottom: 20px; } - -.list:last-child { - margin-bottom: 0px; } - .list:last-child.card { - margin-bottom: 40px; } - -/** - * List Header - * -------------------------------------------------- - */ -.list-header { - margin-top: 20px; - padding: 5px 15px; - background-color: transparent; - color: #222; - font-weight: bold; } - -.card.list .list-item { - padding-right: 1px; - padding-left: 1px; } - -/** - * Cards and Inset Lists - * -------------------------------------------------- - * A card and list-inset are close to the same thing, except a card as a box shadow. - */ -.card, -.list-inset { - overflow: hidden; - margin: 20px 10px; - border-radius: 2px; - background-color: #fff; } - -.card { - padding-top: 1px; - padding-bottom: 1px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); } - .card .item { - border-left: 0; - border-right: 0; } - .card .item:first-child { - border-top: 0; } - .card .item:last-child { - border-bottom: 0; } - -.padding .card, .padding .list-inset { - margin-left: 0; - margin-right: 0; } - -.card .item:first-child, -.list-inset .item:first-child, -.padding > .list .item:first-child { - border-top-left-radius: 2px; - border-top-right-radius: 2px; } - .card .item:first-child .item-content, - .list-inset .item:first-child .item-content, - .padding > .list .item:first-child .item-content { - border-top-left-radius: 2px; - border-top-right-radius: 2px; } - -.card .item:last-child, -.list-inset .item:last-child, -.padding > .list .item:last-child { - border-bottom-right-radius: 2px; - border-bottom-left-radius: 2px; } - .card .item:last-child .item-content, - .list-inset .item:last-child .item-content, - .padding > .list .item:last-child .item-content { - border-bottom-right-radius: 2px; - border-bottom-left-radius: 2px; } - -.card .item:last-child, -.list-inset .item:last-child { - margin-bottom: -1px; } - -.card .item, -.list-inset .item, -.padding > .list .item, -.padding-horizontal > .list .item { - margin-right: 0; - margin-left: 0; } - .card .item.item-input input, - .list-inset .item.item-input input, - .padding > .list .item.item-input input, - .padding-horizontal > .list .item.item-input input { - padding-right: 44px; } - -.padding-left > .list .item { - margin-left: 0; } - -.padding-right > .list .item { - margin-right: 0; } - -/** - * Badges - * -------------------------------------------------- - */ -.badge { - background-color: transparent; - color: #AAAAAA; - z-index: 1; - display: inline-block; - padding: 3px 8px; - min-width: 10px; - border-radius: 10px; - vertical-align: baseline; - text-align: center; - white-space: nowrap; - font-weight: bold; - font-size: 14px; - line-height: 16px; } - .badge:empty { - display: none; } - -.tabs .tab-item .badge.badge-light, -.badge.badge-light { - background-color: #fff; - color: #444; } - -.tabs .tab-item .badge.badge-stable, -.badge.badge-stable { - background-color: #f8f8f8; - color: #444; } - -.tabs .tab-item .badge.badge-positive, -.badge.badge-positive { - background-color: #387ef5; - color: #fff; } - -.tabs .tab-item .badge.badge-calm, -.badge.badge-calm { - background-color: #11c1f3; - color: #fff; } - -.tabs .tab-item .badge.badge-assertive, -.badge.badge-assertive { - background-color: #ef473a; - color: #fff; } - -.tabs .tab-item .badge.badge-balanced, -.badge.badge-balanced { - background-color: #33cd5f; - color: #fff; } - -.tabs .tab-item .badge.badge-energized, -.badge.badge-energized { - background-color: #ffc900; - color: #fff; } - -.tabs .tab-item .badge.badge-royal, -.badge.badge-royal { - background-color: #886aea; - color: #fff; } - -.tabs .tab-item .badge.badge-dark, -.badge.badge-dark { - background-color: #444; - color: #fff; } - -.button .badge { - position: relative; - top: -1px; } - -/** - * Slide Box - * -------------------------------------------------- - */ -.slider { - position: relative; - visibility: hidden; - overflow: hidden; } - -.slider-slides { - position: relative; - height: 100%; } - -.slider-slide { - position: relative; - display: block; - float: left; - width: 100%; - height: 100%; - vertical-align: top; } - -.slider-slide-image > img { - width: 100%; } - -.slider-pager { - position: absolute; - bottom: 20px; - z-index: 1; - width: 100%; - height: 15px; - text-align: center; } - .slider-pager .slider-pager-page { - display: inline-block; - margin: 0px 3px; - width: 15px; - color: #000; - text-decoration: none; - opacity: 0.3; } - .slider-pager .slider-pager-page.active { - -webkit-transition: opacity 0.4s ease-in; - transition: opacity 0.4s ease-in; - opacity: 1; } - -.slider-slide.ng-enter, .slider-slide.ng-leave, .slider-slide.ng-animate, -.slider-pager-page.ng-enter, -.slider-pager-page.ng-leave, -.slider-pager-page.ng-animate { - -webkit-transition: none !important; - transition: none !important; } - -.slider-slide.ng-animate, -.slider-pager-page.ng-animate { - -webkit-animation: none 0s; - animation: none 0s; } - -/** - * Swiper 3.2.7 - * Most modern mobile touch slider and framework with hardware accelerated transitions - * - * http://www.idangero.us/swiper/ - * - * Copyright 2015, Vladimir Kharlampidi - * The iDangero.us - * http://www.idangero.us/ - * - * Licensed under MIT - * - * Released on: December 7, 2015 - */ -.swiper-container { - margin: 0 auto; - position: relative; - overflow: hidden; - /* Fix of Webkit flickering */ - z-index: 1; } - -.swiper-container-no-flexbox .swiper-slide { - float: left; } - -.swiper-container-vertical > .swiper-wrapper { - -webkit-box-orient: vertical; - -moz-box-orient: vertical; - -ms-flex-direction: column; - -webkit-flex-direction: column; - flex-direction: column; } - -.swiper-wrapper { - position: relative; - width: 100%; - height: 100%; - z-index: 1; - display: -webkit-box; - display: -moz-box; - display: -ms-flexbox; - display: -webkit-flex; - display: flex; - -webkit-transition-property: -webkit-transform; - -moz-transition-property: -moz-transform; - -o-transition-property: -o-transform; - -ms-transition-property: -ms-transform; - transition-property: transform; - -webkit-box-sizing: content-box; - -moz-box-sizing: content-box; - box-sizing: content-box; } - -.swiper-container-android .swiper-slide, -.swiper-wrapper { - -webkit-transform: translate3d(0px, 0, 0); - -moz-transform: translate3d(0px, 0, 0); - -o-transform: translate(0px, 0px); - -ms-transform: translate3d(0px, 0, 0); - transform: translate3d(0px, 0, 0); } - -.swiper-container-multirow > .swiper-wrapper { - -webkit-box-lines: multiple; - -moz-box-lines: multiple; - -ms-flex-wrap: wrap; - -webkit-flex-wrap: wrap; - flex-wrap: wrap; } - -.swiper-container-free-mode > .swiper-wrapper { - -webkit-transition-timing-function: ease-out; - -moz-transition-timing-function: ease-out; - -ms-transition-timing-function: ease-out; - -o-transition-timing-function: ease-out; - transition-timing-function: ease-out; - margin: 0 auto; } - -.swiper-slide { - display: block; - -webkit-flex-shrink: 0; - -ms-flex: 0 0 auto; - flex-shrink: 0; - width: 100%; - height: 100%; - position: relative; } - -/* Auto Height */ -.swiper-container-autoheight, -.swiper-container-autoheight .swiper-slide { - height: auto; } - -.swiper-container-autoheight .swiper-wrapper { - -webkit-box-align: start; - -ms-flex-align: start; - -webkit-align-items: flex-start; - align-items: flex-start; - -webkit-transition-property: -webkit-transform, height; - -moz-transition-property: -moz-transform; - -o-transition-property: -o-transform; - -ms-transition-property: -ms-transform; - transition-property: transform, height; } - -/* a11y */ -.swiper-container .swiper-notification { - position: absolute; - left: 0; - top: 0; - pointer-events: none; - opacity: 0; - z-index: -1000; } - -/* IE10 Windows Phone 8 Fixes */ -.swiper-wp8-horizontal { - -ms-touch-action: pan-y; - touch-action: pan-y; } - -.swiper-wp8-vertical { - -ms-touch-action: pan-x; - touch-action: pan-x; } - -/* Arrows */ -.swiper-button-prev, -.swiper-button-next { - position: absolute; - top: 50%; - width: 27px; - height: 44px; - margin-top: -22px; - z-index: 10; - cursor: pointer; - -moz-background-size: 27px 44px; - -webkit-background-size: 27px 44px; - background-size: 27px 44px; - background-position: center; - background-repeat: no-repeat; } - -.swiper-button-prev.swiper-button-disabled, -.swiper-button-next.swiper-button-disabled { - opacity: 0.35; - cursor: auto; - pointer-events: none; } - -.swiper-button-prev, -.swiper-container-rtl .swiper-button-next { - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M0%2C22L22%2C0l2.1%2C2.1L4.2%2C22l19.9%2C19.9L22%2C44L0%2C22L0%2C22L0%2C22z'%20fill%3D'%23007aff'%2F%3E%3C%2Fsvg%3E"); - left: 10px; - right: auto; } - -.swiper-button-prev.swiper-button-black, -.swiper-container-rtl .swiper-button-next.swiper-button-black { - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M0%2C22L22%2C0l2.1%2C2.1L4.2%2C22l19.9%2C19.9L22%2C44L0%2C22L0%2C22L0%2C22z'%20fill%3D'%23000000'%2F%3E%3C%2Fsvg%3E"); } - -.swiper-button-prev.swiper-button-white, -.swiper-container-rtl .swiper-button-next.swiper-button-white { - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M0%2C22L22%2C0l2.1%2C2.1L4.2%2C22l19.9%2C19.9L22%2C44L0%2C22L0%2C22L0%2C22z'%20fill%3D'%23ffffff'%2F%3E%3C%2Fsvg%3E"); } - -.swiper-button-next, -.swiper-container-rtl .swiper-button-prev { - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M27%2C22L27%2C22L5%2C44l-2.1-2.1L22.8%2C22L2.9%2C2.1L5%2C0L27%2C22L27%2C22z'%20fill%3D'%23007aff'%2F%3E%3C%2Fsvg%3E"); - right: 10px; - left: auto; } - -.swiper-button-next.swiper-button-black, -.swiper-container-rtl .swiper-button-prev.swiper-button-black { - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M27%2C22L27%2C22L5%2C44l-2.1-2.1L22.8%2C22L2.9%2C2.1L5%2C0L27%2C22L27%2C22z'%20fill%3D'%23000000'%2F%3E%3C%2Fsvg%3E"); } - -.swiper-button-next.swiper-button-white, -.swiper-container-rtl .swiper-button-prev.swiper-button-white { - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M27%2C22L27%2C22L5%2C44l-2.1-2.1L22.8%2C22L2.9%2C2.1L5%2C0L27%2C22L27%2C22z'%20fill%3D'%23ffffff'%2F%3E%3C%2Fsvg%3E"); } - -/* Pagination Styles */ -.swiper-pagination { - position: absolute; - text-align: center; - -webkit-transition: 300ms; - -moz-transition: 300ms; - -o-transition: 300ms; - transition: 300ms; - -webkit-transform: translate3d(0, 0, 0); - -ms-transform: translate3d(0, 0, 0); - -o-transform: translate3d(0, 0, 0); - transform: translate3d(0, 0, 0); - z-index: 10; } - -.swiper-pagination.swiper-pagination-hidden { - opacity: 0; } - -.swiper-pagination-bullet { - width: 8px; - height: 8px; - display: inline-block; - border-radius: 100%; - background: #000; - opacity: 0.2; } - -button.swiper-pagination-bullet { - border: none; - margin: 0; - padding: 0; - box-shadow: none; - -moz-appearance: none; - -ms-appearance: none; - -webkit-appearance: none; - appearance: none; } - -.swiper-pagination-clickable .swiper-pagination-bullet { - cursor: pointer; } - -.swiper-pagination-white .swiper-pagination-bullet { - background: #fff; } - -.swiper-pagination-bullet-active { - opacity: 1; } - -.swiper-pagination-white .swiper-pagination-bullet-active { - background: #fff; } - -.swiper-pagination-black .swiper-pagination-bullet-active { - background: #000; } - -.swiper-container-vertical > .swiper-pagination { - right: 10px; - top: 50%; - -webkit-transform: translate3d(0px, -50%, 0); - -moz-transform: translate3d(0px, -50%, 0); - -o-transform: translate(0px, -50%); - -ms-transform: translate3d(0px, -50%, 0); - transform: translate3d(0px, -50%, 0); } - -.swiper-container-vertical > .swiper-pagination .swiper-pagination-bullet { - margin: 5px 0; - display: block; } - -.swiper-container-horizontal > .swiper-pagination { - bottom: 10px; - left: 0; - width: 100%; } - -.swiper-container-horizontal > .swiper-pagination .swiper-pagination-bullet { - margin: 0 5px; } - -/* 3D Container */ -.swiper-container-3d { - -webkit-perspective: 1200px; - -moz-perspective: 1200px; - -o-perspective: 1200px; - perspective: 1200px; } - -.swiper-container-3d .swiper-wrapper, -.swiper-container-3d .swiper-slide, -.swiper-container-3d .swiper-slide-shadow-left, -.swiper-container-3d .swiper-slide-shadow-right, -.swiper-container-3d .swiper-slide-shadow-top, -.swiper-container-3d .swiper-slide-shadow-bottom, -.swiper-container-3d .swiper-cube-shadow { - -webkit-transform-style: preserve-3d; - -moz-transform-style: preserve-3d; - -ms-transform-style: preserve-3d; - transform-style: preserve-3d; } - -.swiper-container-3d .swiper-slide-shadow-left, -.swiper-container-3d .swiper-slide-shadow-right, -.swiper-container-3d .swiper-slide-shadow-top, -.swiper-container-3d .swiper-slide-shadow-bottom { - position: absolute; - left: 0; - top: 0; - width: 100%; - height: 100%; - pointer-events: none; - z-index: 10; } - -.swiper-container-3d .swiper-slide-shadow-left { - background-image: -webkit-gradient(linear, left top, right top, from(rgba(0, 0, 0, 0.5)), to(transparent)); - /* Safari 4+, Chrome */ - background-image: -webkit-linear-gradient(right, rgba(0, 0, 0, 0.5), transparent); - /* Chrome 10+, Safari 5.1+, iOS 5+ */ - background-image: -moz-linear-gradient(right, rgba(0, 0, 0, 0.5), transparent); - /* Firefox 3.6-15 */ - background-image: -o-linear-gradient(right, rgba(0, 0, 0, 0.5), transparent); - /* Opera 11.10-12.00 */ - background-image: linear-gradient(to left, rgba(0, 0, 0, 0.5), transparent); - /* Firefox 16+, IE10, Opera 12.50+ */ } - -.swiper-container-3d .swiper-slide-shadow-right { - background-image: -webkit-gradient(linear, right top, left top, from(rgba(0, 0, 0, 0.5)), to(transparent)); - /* Safari 4+, Chrome */ - background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, 0.5), transparent); - /* Chrome 10+, Safari 5.1+, iOS 5+ */ - background-image: -moz-linear-gradient(left, rgba(0, 0, 0, 0.5), transparent); - /* Firefox 3.6-15 */ - background-image: -o-linear-gradient(left, rgba(0, 0, 0, 0.5), transparent); - /* Opera 11.10-12.00 */ - background-image: linear-gradient(to right, rgba(0, 0, 0, 0.5), transparent); - /* Firefox 16+, IE10, Opera 12.50+ */ } - -.swiper-container-3d .swiper-slide-shadow-top { - background-image: -webkit-gradient(linear, left top, left bottom, from(rgba(0, 0, 0, 0.5)), to(transparent)); - /* Safari 4+, Chrome */ - background-image: -webkit-linear-gradient(bottom, rgba(0, 0, 0, 0.5), transparent); - /* Chrome 10+, Safari 5.1+, iOS 5+ */ - background-image: -moz-linear-gradient(bottom, rgba(0, 0, 0, 0.5), transparent); - /* Firefox 3.6-15 */ - background-image: -o-linear-gradient(bottom, rgba(0, 0, 0, 0.5), transparent); - /* Opera 11.10-12.00 */ - background-image: linear-gradient(to top, rgba(0, 0, 0, 0.5), transparent); - /* Firefox 16+, IE10, Opera 12.50+ */ } - -.swiper-container-3d .swiper-slide-shadow-bottom { - background-image: -webkit-gradient(linear, left bottom, left top, from(rgba(0, 0, 0, 0.5)), to(transparent)); - /* Safari 4+, Chrome */ - background-image: -webkit-linear-gradient(top, rgba(0, 0, 0, 0.5), transparent); - /* Chrome 10+, Safari 5.1+, iOS 5+ */ - background-image: -moz-linear-gradient(top, rgba(0, 0, 0, 0.5), transparent); - /* Firefox 3.6-15 */ - background-image: -o-linear-gradient(top, rgba(0, 0, 0, 0.5), transparent); - /* Opera 11.10-12.00 */ - background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.5), transparent); - /* Firefox 16+, IE10, Opera 12.50+ */ } - -/* Coverflow */ -.swiper-container-coverflow .swiper-wrapper { - /* Windows 8 IE 10 fix */ - -ms-perspective: 1200px; } - -/* Fade */ -.swiper-container-fade.swiper-container-free-mode .swiper-slide { - -webkit-transition-timing-function: ease-out; - -moz-transition-timing-function: ease-out; - -ms-transition-timing-function: ease-out; - -o-transition-timing-function: ease-out; - transition-timing-function: ease-out; } - -.swiper-container-fade .swiper-slide { - pointer-events: none; } - -.swiper-container-fade .swiper-slide .swiper-slide { - pointer-events: none; } - -.swiper-container-fade .swiper-slide-active, -.swiper-container-fade .swiper-slide-active .swiper-slide-active { - pointer-events: auto; } - -/* Cube */ -.swiper-container-cube { - overflow: visible; } - -.swiper-container-cube .swiper-slide { - pointer-events: none; - visibility: hidden; - -webkit-transform-origin: 0 0; - -moz-transform-origin: 0 0; - -ms-transform-origin: 0 0; - transform-origin: 0 0; - -webkit-backface-visibility: hidden; - -moz-backface-visibility: hidden; - -ms-backface-visibility: hidden; - backface-visibility: hidden; - width: 100%; - height: 100%; - z-index: 1; } - -.swiper-container-cube.swiper-container-rtl .swiper-slide { - -webkit-transform-origin: 100% 0; - -moz-transform-origin: 100% 0; - -ms-transform-origin: 100% 0; - transform-origin: 100% 0; } - -.swiper-container-cube .swiper-slide-active, -.swiper-container-cube .swiper-slide-next, -.swiper-container-cube .swiper-slide-prev, -.swiper-container-cube .swiper-slide-next + .swiper-slide { - pointer-events: auto; - visibility: visible; } - -.swiper-container-cube .swiper-slide-shadow-top, -.swiper-container-cube .swiper-slide-shadow-bottom, -.swiper-container-cube .swiper-slide-shadow-left, -.swiper-container-cube .swiper-slide-shadow-right { - z-index: 0; - -webkit-backface-visibility: hidden; - -moz-backface-visibility: hidden; - -ms-backface-visibility: hidden; - backface-visibility: hidden; } - -.swiper-container-cube .swiper-cube-shadow { - position: absolute; - left: 0; - bottom: 0px; - width: 100%; - height: 100%; - background: #000; - opacity: 0.6; - -webkit-filter: blur(50px); - filter: blur(50px); - z-index: 0; } - -/* Scrollbar */ -.swiper-scrollbar { - border-radius: 10px; - position: relative; - -ms-touch-action: none; - background: rgba(0, 0, 0, 0.1); } - -.swiper-container-horizontal > .swiper-scrollbar { - position: absolute; - left: 1%; - bottom: 3px; - z-index: 50; - height: 5px; - width: 98%; } - -.swiper-container-vertical > .swiper-scrollbar { - position: absolute; - right: 3px; - top: 1%; - z-index: 50; - width: 5px; - height: 98%; } - -.swiper-scrollbar-drag { - height: 100%; - width: 100%; - position: relative; - background: rgba(0, 0, 0, 0.5); - border-radius: 10px; - left: 0; - top: 0; } - -.swiper-scrollbar-cursor-drag { - cursor: move; } - -/* Preloader */ -.swiper-lazy-preloader { - width: 42px; - height: 42px; - position: absolute; - left: 50%; - top: 50%; - margin-left: -21px; - margin-top: -21px; - z-index: 10; - -webkit-transform-origin: 50%; - -moz-transform-origin: 50%; - transform-origin: 50%; - -webkit-animation: swiper-preloader-spin 1s steps(12, end) infinite; - -moz-animation: swiper-preloader-spin 1s steps(12, end) infinite; - animation: swiper-preloader-spin 1s steps(12, end) infinite; } - -.swiper-lazy-preloader:after { - display: block; - content: ""; - width: 100%; - height: 100%; - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20viewBox%3D'0%200%20120%20120'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20xmlns%3Axlink%3D'http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink'%3E%3Cdefs%3E%3Cline%20id%3D'l'%20x1%3D'60'%20x2%3D'60'%20y1%3D'7'%20y2%3D'27'%20stroke%3D'%236c6c6c'%20stroke-width%3D'11'%20stroke-linecap%3D'round'%2F%3E%3C%2Fdefs%3E%3Cg%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(30%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(60%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(90%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(120%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(150%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.37'%20transform%3D'rotate(180%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.46'%20transform%3D'rotate(210%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.56'%20transform%3D'rotate(240%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.66'%20transform%3D'rotate(270%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.75'%20transform%3D'rotate(300%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.85'%20transform%3D'rotate(330%2060%2C60)'%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E"); - background-position: 50%; - -webkit-background-size: 100%; - background-size: 100%; - background-repeat: no-repeat; } - -.swiper-lazy-preloader-white:after { - background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20viewBox%3D'0%200%20120%20120'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20xmlns%3Axlink%3D'http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink'%3E%3Cdefs%3E%3Cline%20id%3D'l'%20x1%3D'60'%20x2%3D'60'%20y1%3D'7'%20y2%3D'27'%20stroke%3D'%23fff'%20stroke-width%3D'11'%20stroke-linecap%3D'round'%2F%3E%3C%2Fdefs%3E%3Cg%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(30%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(60%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(90%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(120%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(150%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.37'%20transform%3D'rotate(180%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.46'%20transform%3D'rotate(210%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.56'%20transform%3D'rotate(240%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.66'%20transform%3D'rotate(270%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.75'%20transform%3D'rotate(300%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.85'%20transform%3D'rotate(330%2060%2C60)'%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E"); } - -@-webkit-keyframes swiper-preloader-spin { - 100% { - -webkit-transform: rotate(360deg); } } - -@keyframes swiper-preloader-spin { - 100% { - transform: rotate(360deg); } } - -ion-slides { - width: 100%; - height: 100%; - display: block; } - -.slide-zoom { - display: block; - width: 100%; - text-align: center; } - -.swiper-container { - width: 100%; - height: 100%; - padding: 0; - overflow: hidden; } - -.swiper-wrapper { - position: absolute; - left: 0; - top: 0; - width: 100%; - height: 100%; - padding: 0; } - -.swiper-slide { - width: 100%; - height: 100%; - box-sizing: border-box; - /* Center slide text vertically */ } - .swiper-slide img { - width: auto; - height: auto; - max-width: 100%; - max-height: 100%; } - -.scroll-refresher { - position: absolute; - top: -60px; - right: 0; - left: 0; - overflow: hidden; - margin: auto; - height: 60px; } - .scroll-refresher .ionic-refresher-content { - position: absolute; - bottom: 15px; - left: 0; - width: 100%; - color: #666666; - text-align: center; - font-size: 30px; } - .scroll-refresher .ionic-refresher-content .text-refreshing, - .scroll-refresher .ionic-refresher-content .text-pulling { - font-size: 16px; - line-height: 16px; } - .scroll-refresher .ionic-refresher-content.ionic-refresher-with-text { - bottom: 10px; } - .scroll-refresher .icon-refreshing, - .scroll-refresher .icon-pulling { - width: 100%; - -webkit-backface-visibility: hidden; - backface-visibility: hidden; - -webkit-transform-style: preserve-3d; - transform-style: preserve-3d; } - .scroll-refresher .icon-pulling { - -webkit-animation-name: refresh-spin-back; - animation-name: refresh-spin-back; - -webkit-animation-duration: 200ms; - animation-duration: 200ms; - -webkit-animation-timing-function: linear; - animation-timing-function: linear; - -webkit-animation-fill-mode: none; - animation-fill-mode: none; - -webkit-transform: translate3d(0, 0, 0) rotate(0deg); - transform: translate3d(0, 0, 0) rotate(0deg); } - .scroll-refresher .icon-refreshing, - .scroll-refresher .text-refreshing { - display: none; } - .scroll-refresher .icon-refreshing { - -webkit-animation-duration: 1.5s; - animation-duration: 1.5s; } - .scroll-refresher.active .icon-pulling:not(.pulling-rotation-disabled) { - -webkit-animation-name: refresh-spin; - animation-name: refresh-spin; - -webkit-transform: translate3d(0, 0, 0) rotate(-180deg); - transform: translate3d(0, 0, 0) rotate(-180deg); } - .scroll-refresher.active.refreshing { - -webkit-transition: -webkit-transform 0.2s; - transition: -webkit-transform 0.2s; - -webkit-transition: transform 0.2s; - transition: transform 0.2s; - -webkit-transform: scale(1, 1); - transform: scale(1, 1); } - .scroll-refresher.active.refreshing .icon-pulling, - .scroll-refresher.active.refreshing .text-pulling { - display: none; } - .scroll-refresher.active.refreshing .icon-refreshing, - .scroll-refresher.active.refreshing .text-refreshing { - display: block; } - .scroll-refresher.active.refreshing.refreshing-tail { - -webkit-transform: scale(0, 0); - transform: scale(0, 0); } - -.overflow-scroll > .scroll { - -webkit-overflow-scrolling: touch; - width: 100%; } - .overflow-scroll > .scroll.overscroll { - position: fixed; - right: 0; - left: 0; } - -.overflow-scroll.padding > .scroll.overscroll { - padding: 10px; } - -@-webkit-keyframes refresh-spin { - 0% { - -webkit-transform: translate3d(0, 0, 0) rotate(0); } - 100% { - -webkit-transform: translate3d(0, 0, 0) rotate(180deg); } } - -@keyframes refresh-spin { - 0% { - transform: translate3d(0, 0, 0) rotate(0); } - 100% { - transform: translate3d(0, 0, 0) rotate(180deg); } } - -@-webkit-keyframes refresh-spin-back { - 0% { - -webkit-transform: translate3d(0, 0, 0) rotate(180deg); } - 100% { - -webkit-transform: translate3d(0, 0, 0) rotate(0); } } - -@keyframes refresh-spin-back { - 0% { - transform: translate3d(0, 0, 0) rotate(180deg); } - 100% { - transform: translate3d(0, 0, 0) rotate(0); } } - -/** - * Spinners - * -------------------------------------------------- - */ -.spinner { - stroke: #444; - fill: #444; } - .spinner svg { - width: 28px; - height: 28px; } - .spinner.spinner-light { - stroke: #fff; - fill: #fff; } - .spinner.spinner-stable { - stroke: #f8f8f8; - fill: #f8f8f8; } - .spinner.spinner-positive { - stroke: #387ef5; - fill: #387ef5; } - .spinner.spinner-calm { - stroke: #11c1f3; - fill: #11c1f3; } - .spinner.spinner-balanced { - stroke: #33cd5f; - fill: #33cd5f; } - .spinner.spinner-assertive { - stroke: #ef473a; - fill: #ef473a; } - .spinner.spinner-energized { - stroke: #ffc900; - fill: #ffc900; } - .spinner.spinner-royal { - stroke: #886aea; - fill: #886aea; } - .spinner.spinner-dark { - stroke: #444; - fill: #444; } - -.spinner-android { - stroke: #4b8bf4; } - -.spinner-ios, -.spinner-ios-small { - stroke: #69717d; } - -.spinner-spiral .stop1 { - stop-color: #fff; - stop-opacity: 0; } - -.spinner-spiral.spinner-light .stop1 { - stop-color: #444; } - -.spinner-spiral.spinner-light .stop2 { - stop-color: #fff; } - -.spinner-spiral.spinner-stable .stop2 { - stop-color: #f8f8f8; } - -.spinner-spiral.spinner-positive .stop2 { - stop-color: #387ef5; } - -.spinner-spiral.spinner-calm .stop2 { - stop-color: #11c1f3; } - -.spinner-spiral.spinner-balanced .stop2 { - stop-color: #33cd5f; } - -.spinner-spiral.spinner-assertive .stop2 { - stop-color: #ef473a; } - -.spinner-spiral.spinner-energized .stop2 { - stop-color: #ffc900; } - -.spinner-spiral.spinner-royal .stop2 { - stop-color: #886aea; } - -.spinner-spiral.spinner-dark .stop2 { - stop-color: #444; } - -/** - * Forms - * -------------------------------------------------- - */ -form { - margin: 0 0 1.42857; } - -legend { - display: block; - margin-bottom: 1.42857; - padding: 0; - width: 100%; - border: 1px solid #ddd; - color: #444; - font-size: 21px; - line-height: 2.85714; } - legend small { - color: #f8f8f8; - font-size: 1.07143; } - -label, -input, -button, -select, -textarea { - font-weight: normal; - font-size: 14px; - line-height: 1.42857; } - -input, -button, -select, -textarea { - font-family: "-apple-system", "Helvetica Neue", "Roboto", "Segoe UI", sans-serif; } - -.item-input { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - position: relative; - overflow: hidden; - padding: 6px 0 5px 16px; } - .item-input input { - -webkit-border-radius: 0; - border-radius: 0; - -webkit-box-flex: 1; - -webkit-flex: 1 220px; - -moz-box-flex: 1; - -moz-flex: 1 220px; - -ms-flex: 1 220px; - flex: 1 220px; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - margin: 0; - padding-right: 24px; - background-color: transparent; } - .item-input .button .icon { - -webkit-box-flex: 0; - -webkit-flex: 0 0 24px; - -moz-box-flex: 0; - -moz-flex: 0 0 24px; - -ms-flex: 0 0 24px; - flex: 0 0 24px; - position: static; - display: inline-block; - height: auto; - text-align: center; - font-size: 16px; } - .item-input .button-bar { - -webkit-border-radius: 0; - border-radius: 0; - -webkit-box-flex: 1; - -webkit-flex: 1 0 220px; - -moz-box-flex: 1; - -moz-flex: 1 0 220px; - -ms-flex: 1 0 220px; - flex: 1 0 220px; - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; } - .item-input .icon { - min-width: 14px; } - -.platform-windowsphone .item-input input { - flex-shrink: 1; } - -.item-input-inset { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - position: relative; - overflow: hidden; - padding: 10.66667px; } - -.item-input-wrapper { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-flex: 1; - -webkit-flex: 1 0; - -moz-box-flex: 1; - -moz-flex: 1 0; - -ms-flex: 1 0; - flex: 1 0; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - -webkit-border-radius: 4px; - border-radius: 4px; - padding-right: 8px; - padding-left: 8px; - background: #eee; } - -.item-input-inset .item-input-wrapper input { - padding-left: 4px; - height: 29px; - background: transparent; - line-height: 18px; } - -.item-input-wrapper ~ .button { - margin-left: 10.66667px; } - -.input-label { - display: table; - padding: 7px 10px 7px 0px; - max-width: 200px; - width: 35%; - color: #444; - font-size: 16px; } - -.placeholder-icon { - color: #aaa; } - .placeholder-icon:first-child { - padding-right: 6px; } - .placeholder-icon:last-child { - padding-left: 6px; } - -.item-stacked-label { - display: block; - background-color: transparent; - box-shadow: none; } - .item-stacked-label .input-label, .item-stacked-label .icon { - display: inline-block; - padding: 4px 0 0 0px; - vertical-align: middle; } - -.item-stacked-label input, -.item-stacked-label textarea { - -webkit-border-radius: 2px; - border-radius: 2px; - padding: 4px 8px 3px 0; - border: none; - background-color: #fff; } - -.item-stacked-label input { - overflow: hidden; - height: 46px; } - -.item-select.item-stacked-label select { - position: relative; - padding: 0px; - max-width: 90%; - direction: ltr; - white-space: pre-wrap; - margin: -3px; } - -.item-floating-label { - display: block; - background-color: transparent; - box-shadow: none; } - .item-floating-label .input-label { - position: relative; - padding: 5px 0 0 0; - opacity: 0; - top: 10px; - -webkit-transition: opacity 0.15s ease-in, top 0.2s linear; - transition: opacity 0.15s ease-in, top 0.2s linear; } - .item-floating-label .input-label.has-input { - opacity: 1; - top: 0; - -webkit-transition: opacity 0.15s ease-in, top 0.2s linear; - transition: opacity 0.15s ease-in, top 0.2s linear; } - -textarea, -input[type="text"], -input[type="password"], -input[type="datetime"], -input[type="datetime-local"], -input[type="date"], -input[type="month"], -input[type="time"], -input[type="week"], -input[type="number"], -input[type="email"], -input[type="url"], -input[type="search"], -input[type="tel"], -input[type="color"] { - display: block; - padding-top: 2px; - padding-left: 0; - height: 34px; - color: #111; - vertical-align: middle; - font-size: 14px; - line-height: 16px; } - -.platform-ios input[type="datetime-local"], -.platform-ios input[type="date"], -.platform-ios input[type="month"], -.platform-ios input[type="time"], -.platform-ios input[type="week"], -.platform-android input[type="datetime-local"], -.platform-android input[type="date"], -.platform-android input[type="month"], -.platform-android input[type="time"], -.platform-android input[type="week"] { - padding-top: 8px; } - -.item-input input, -.item-input textarea { - width: 100%; } - -textarea { - padding-left: 0; } - textarea::-moz-placeholder { - color: #aaaaaa; } - textarea:-ms-input-placeholder { - color: #aaaaaa; } - textarea::-webkit-input-placeholder { - color: #aaaaaa; - text-indent: -3px; } - -textarea { - height: auto; } - -textarea, -input[type="text"], -input[type="password"], -input[type="datetime"], -input[type="datetime-local"], -input[type="date"], -input[type="month"], -input[type="time"], -input[type="week"], -input[type="number"], -input[type="email"], -input[type="url"], -input[type="search"], -input[type="tel"], -input[type="color"] { - border: 0; } - -input[type="radio"], -input[type="checkbox"] { - margin: 0; - line-height: normal; } - -.item-input input[type="file"], -.item-input input[type="image"], -.item-input input[type="submit"], -.item-input input[type="reset"], -.item-input input[type="button"], -.item-input input[type="radio"], -.item-input input[type="checkbox"] { - width: auto; } - -input[type="file"] { - line-height: 34px; } - -.previous-input-focus, -.cloned-text-input + input, -.cloned-text-input + textarea { - position: absolute !important; - left: -9999px; - width: 200px; } - -input::-moz-placeholder, -textarea::-moz-placeholder { - color: #aaaaaa; } - -input:-ms-input-placeholder, -textarea:-ms-input-placeholder { - color: #aaaaaa; } - -input::-webkit-input-placeholder, -textarea::-webkit-input-placeholder { - color: #aaaaaa; - text-indent: 0; } - -input[disabled], -select[disabled], -textarea[disabled], -input[readonly]:not(.cloned-text-input), -textarea[readonly]:not(.cloned-text-input), -select[readonly] { - background-color: #f8f8f8; - cursor: not-allowed; } - -input[type="radio"][disabled], -input[type="checkbox"][disabled], -input[type="radio"][readonly], -input[type="checkbox"][readonly] { - background-color: transparent; } - -/** - * Checkbox - * -------------------------------------------------- - */ -.checkbox { - position: relative; - display: inline-block; - padding: 7px 7px; - cursor: pointer; } - .checkbox input:before, - .checkbox .checkbox-icon:before { - border-color: #ddd; } - .checkbox input:checked:before, - .checkbox input:checked + .checkbox-icon:before { - background: #387ef5; - border-color: #387ef5; } - -.checkbox-light input:before, -.checkbox-light .checkbox-icon:before { - border-color: #ddd; } - -.checkbox-light input:checked:before, -.checkbox-light input:checked + .checkbox-icon:before { - background: #ddd; - border-color: #ddd; } - -.checkbox-stable input:before, -.checkbox-stable .checkbox-icon:before { - border-color: #b2b2b2; } - -.checkbox-stable input:checked:before, -.checkbox-stable input:checked + .checkbox-icon:before { - background: #b2b2b2; - border-color: #b2b2b2; } - -.checkbox-positive input:before, -.checkbox-positive .checkbox-icon:before { - border-color: #387ef5; } - -.checkbox-positive input:checked:before, -.checkbox-positive input:checked + .checkbox-icon:before { - background: #387ef5; - border-color: #387ef5; } - -.checkbox-calm input:before, -.checkbox-calm .checkbox-icon:before { - border-color: #11c1f3; } - -.checkbox-calm input:checked:before, -.checkbox-calm input:checked + .checkbox-icon:before { - background: #11c1f3; - border-color: #11c1f3; } - -.checkbox-assertive input:before, -.checkbox-assertive .checkbox-icon:before { - border-color: #ef473a; } - -.checkbox-assertive input:checked:before, -.checkbox-assertive input:checked + .checkbox-icon:before { - background: #ef473a; - border-color: #ef473a; } - -.checkbox-balanced input:before, -.checkbox-balanced .checkbox-icon:before { - border-color: #33cd5f; } - -.checkbox-balanced input:checked:before, -.checkbox-balanced input:checked + .checkbox-icon:before { - background: #33cd5f; - border-color: #33cd5f; } - -.checkbox-energized input:before, -.checkbox-energized .checkbox-icon:before { - border-color: #ffc900; } - -.checkbox-energized input:checked:before, -.checkbox-energized input:checked + .checkbox-icon:before { - background: #ffc900; - border-color: #ffc900; } - -.checkbox-royal input:before, -.checkbox-royal .checkbox-icon:before { - border-color: #886aea; } - -.checkbox-royal input:checked:before, -.checkbox-royal input:checked + .checkbox-icon:before { - background: #886aea; - border-color: #886aea; } - -.checkbox-dark input:before, -.checkbox-dark .checkbox-icon:before { - border-color: #444; } - -.checkbox-dark input:checked:before, -.checkbox-dark input:checked + .checkbox-icon:before { - background: #444; - border-color: #444; } - -.checkbox input:disabled:before, -.checkbox input:disabled + .checkbox-icon:before { - border-color: #ddd; } - -.checkbox input:disabled:checked:before, -.checkbox input:disabled:checked + .checkbox-icon:before { - background: #ddd; } - -.checkbox.checkbox-input-hidden input { - display: none !important; } - -.checkbox input, -.checkbox-icon { - position: relative; - width: 28px; - height: 28px; - display: block; - border: 0; - background: transparent; - cursor: pointer; - -webkit-appearance: none; } - .checkbox input:before, - .checkbox-icon:before { - display: table; - width: 100%; - height: 100%; - border-width: 1px; - border-style: solid; - border-radius: 28px; - background: #fff; - content: ' '; - -webkit-transition: background-color 20ms ease-in-out; - transition: background-color 20ms ease-in-out; } - -.checkbox input:checked:before, -input:checked + .checkbox-icon:before { - border-width: 2px; } - -.checkbox input:after, -.checkbox-icon:after { - -webkit-transition: opacity 0.05s ease-in-out; - transition: opacity 0.05s ease-in-out; - -webkit-transform: rotate(-45deg); - transform: rotate(-45deg); - position: absolute; - top: 33%; - left: 25%; - display: table; - width: 14px; - height: 6px; - border: 1px solid #fff; - border-top: 0; - border-right: 0; - content: ' '; - opacity: 0; } - -.platform-android .checkbox-platform input:before, -.platform-android .checkbox-platform .checkbox-icon:before, -.checkbox-square input:before, -.checkbox-square .checkbox-icon:before { - border-radius: 2px; - width: 72%; - height: 72%; - margin-top: 14%; - margin-left: 14%; - border-width: 2px; } - -.platform-android .checkbox-platform input:after, -.platform-android .checkbox-platform .checkbox-icon:after, -.checkbox-square input:after, -.checkbox-square .checkbox-icon:after { - border-width: 2px; - top: 19%; - left: 25%; - width: 13px; - height: 7px; } - -.platform-android .item-checkbox-right .checkbox-square .checkbox-icon::after { - top: 31%; } - -.grade-c .checkbox input:after, -.grade-c .checkbox-icon:after { - -webkit-transform: rotate(0); - transform: rotate(0); - top: 3px; - left: 4px; - border: none; - color: #fff; - content: '\2713'; - font-weight: bold; - font-size: 20px; } - -.checkbox input:checked:after, -input:checked + .checkbox-icon:after { - opacity: 1; } - -.item-checkbox { - padding-left: 60px; } - .item-checkbox.active { - box-shadow: none; } - -.item-checkbox .checkbox { - position: absolute; - top: 50%; - right: 8px; - left: 8px; - z-index: 3; - margin-top: -21px; } - -.item-checkbox.item-checkbox-right { - padding-right: 60px; - padding-left: 16px; } - -.item-checkbox-right .checkbox input, -.item-checkbox-right .checkbox-icon { - float: right; } - -/** - * Toggle - * -------------------------------------------------- - */ -.item-toggle { - pointer-events: none; } - -.toggle { - position: relative; - display: inline-block; - pointer-events: auto; - margin: -5px; - padding: 5px; } - .toggle input:checked + .track { - border-color: #4cd964; - background-color: #4cd964; } - .toggle.dragging .handle { - background-color: #f2f2f2 !important; } - -.toggle.toggle-light input:checked + .track { - border-color: #ddd; - background-color: #ddd; } - -.toggle.toggle-stable input:checked + .track { - border-color: #b2b2b2; - background-color: #b2b2b2; } - -.toggle.toggle-positive input:checked + .track { - border-color: #387ef5; - background-color: #387ef5; } - -.toggle.toggle-calm input:checked + .track { - border-color: #11c1f3; - background-color: #11c1f3; } - -.toggle.toggle-assertive input:checked + .track { - border-color: #ef473a; - background-color: #ef473a; } - -.toggle.toggle-balanced input:checked + .track { - border-color: #33cd5f; - background-color: #33cd5f; } - -.toggle.toggle-energized input:checked + .track { - border-color: #ffc900; - background-color: #ffc900; } - -.toggle.toggle-royal input:checked + .track { - border-color: #886aea; - background-color: #886aea; } - -.toggle.toggle-dark input:checked + .track { - border-color: #444; - background-color: #444; } - -.toggle input { - display: none; } - -/* the track appearance when the toggle is "off" */ -.toggle .track { - -webkit-transition-timing-function: ease-in-out; - transition-timing-function: ease-in-out; - -webkit-transition-duration: 0.3s; - transition-duration: 0.3s; - -webkit-transition-property: background-color, border; - transition-property: background-color, border; - display: inline-block; - box-sizing: border-box; - width: 51px; - height: 31px; - border: solid 2px #e6e6e6; - border-radius: 20px; - background-color: #fff; - content: ' '; - cursor: pointer; - pointer-events: none; } - -/* Fix to avoid background color bleeding */ -/* (occurred on (at least) Android 4.2, Asus MeMO Pad HD7 ME173X) */ -.platform-android4_2 .toggle .track { - -webkit-background-clip: padding-box; } - -/* the handle (circle) thats inside the toggle's track area */ -/* also the handle's appearance when it is "off" */ -.toggle .handle { - -webkit-transition: 0.3s cubic-bezier(0, 1.1, 1, 1.1); - transition: 0.3s cubic-bezier(0, 1.1, 1, 1.1); - -webkit-transition-property: background-color, transform; - transition-property: background-color, transform; - position: absolute; - display: block; - width: 27px; - height: 27px; - border-radius: 27px; - background-color: #fff; - top: 7px; - left: 7px; - box-shadow: 0 2px 7px rgba(0, 0, 0, 0.35), 0 1px 1px rgba(0, 0, 0, 0.15); } - .toggle .handle:before { - position: absolute; - top: -4px; - left: -21.5px; - padding: 18.5px 34px; - content: " "; } - -.toggle input:checked + .track .handle { - -webkit-transform: translate3d(20px, 0, 0); - transform: translate3d(20px, 0, 0); - background-color: #fff; } - -.item-toggle.active { - box-shadow: none; } - -.item-toggle, -.item-toggle.item-complex .item-content { - padding-right: 99px; } - -.item-toggle.item-complex { - padding-right: 0; } - -.item-toggle .toggle { - position: absolute; - top: 10px; - right: 16px; - z-index: 3; } - -.toggle input:disabled + .track { - opacity: .6; } - -.toggle-small .track { - border: 0; - width: 34px; - height: 15px; - background: #9e9e9e; } - -.toggle-small input:checked + .track { - background: rgba(0, 150, 137, 0.5); } - -.toggle-small .handle { - top: 2px; - left: 4px; - width: 21px; - height: 21px; - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.25); } - -.toggle-small input:checked + .track .handle { - -webkit-transform: translate3d(16px, 0, 0); - transform: translate3d(16px, 0, 0); - background: #009689; } - -.toggle-small.item-toggle .toggle { - top: 19px; } - -.toggle-small .toggle-light input:checked + .track { - background-color: rgba(221, 221, 221, 0.5); } - -.toggle-small .toggle-light input:checked + .track .handle { - background-color: #ddd; } - -.toggle-small .toggle-stable input:checked + .track { - background-color: rgba(178, 178, 178, 0.5); } - -.toggle-small .toggle-stable input:checked + .track .handle { - background-color: #b2b2b2; } - -.toggle-small .toggle-positive input:checked + .track { - background-color: rgba(56, 126, 245, 0.5); } - -.toggle-small .toggle-positive input:checked + .track .handle { - background-color: #387ef5; } - -.toggle-small .toggle-calm input:checked + .track { - background-color: rgba(17, 193, 243, 0.5); } - -.toggle-small .toggle-calm input:checked + .track .handle { - background-color: #11c1f3; } - -.toggle-small .toggle-assertive input:checked + .track { - background-color: rgba(239, 71, 58, 0.5); } - -.toggle-small .toggle-assertive input:checked + .track .handle { - background-color: #ef473a; } - -.toggle-small .toggle-balanced input:checked + .track { - background-color: rgba(51, 205, 95, 0.5); } - -.toggle-small .toggle-balanced input:checked + .track .handle { - background-color: #33cd5f; } - -.toggle-small .toggle-energized input:checked + .track { - background-color: rgba(255, 201, 0, 0.5); } - -.toggle-small .toggle-energized input:checked + .track .handle { - background-color: #ffc900; } - -.toggle-small .toggle-royal input:checked + .track { - background-color: rgba(136, 106, 234, 0.5); } - -.toggle-small .toggle-royal input:checked + .track .handle { - background-color: #886aea; } - -.toggle-small .toggle-dark input:checked + .track { - background-color: rgba(68, 68, 68, 0.5); } - -.toggle-small .toggle-dark input:checked + .track .handle { - background-color: #444; } - -/** - * Radio Button Inputs - * -------------------------------------------------- - */ -.item-radio { - padding: 0; } - .item-radio:hover { - cursor: pointer; } - -.item-radio .item-content { - /* give some room to the right for the checkmark icon */ - padding-right: 64px; } - -.item-radio .radio-icon { - /* checkmark icon will be hidden by default */ - position: absolute; - top: 0; - right: 0; - z-index: 3; - visibility: hidden; - padding: 14px; - height: 100%; - font-size: 24px; } - -.item-radio input { - /* hide any radio button inputs elements (the ugly circles) */ - position: absolute; - left: -9999px; } - .item-radio input:checked + .radio-content .item-content { - /* style the item content when its checked */ - background: #f7f7f7; } - .item-radio input:checked + .radio-content .radio-icon { - /* show the checkmark icon when its checked */ - visibility: visible; } - -/** - * Range - * -------------------------------------------------- - */ -.range input { - display: inline-block; - overflow: hidden; - margin-top: 5px; - margin-bottom: 5px; - padding-right: 2px; - padding-left: 1px; - width: auto; - height: 43px; - outline: none; - background: -webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(0%, #ccc), color-stop(100%, #ccc)); - background: linear-gradient(to right, #ccc 0%, #ccc 100%); - background-position: center; - background-size: 99% 2px; - background-repeat: no-repeat; - -webkit-appearance: none; - /* - &::-ms-track{ - background: transparent; - border-color: transparent; - border-width: 11px 0 16px; - color:transparent; - margin-top:20px; - } - &::-ms-thumb { - width: $range-slider-width; - height: $range-slider-height; - border-radius: $range-slider-border-radius; - background-color: $toggle-handle-off-bg-color; - border-color:$toggle-handle-off-bg-color; - box-shadow: $range-slider-box-shadow; - margin-left:1px; - margin-right:1px; - outline:none; - } - &::-ms-fill-upper { - height: $range-track-height; - background:$range-default-track-bg; - } - */ } - .range input::-moz-focus-outer { - /* hide the focus outline in Firefox */ - border: 0; } - .range input::-webkit-slider-thumb { - position: relative; - width: 28px; - height: 28px; - border-radius: 50%; - background-color: #fff; - box-shadow: 0 0 2px rgba(0, 0, 0, 0.3), 0 3px 5px rgba(0, 0, 0, 0.2); - cursor: pointer; - -webkit-appearance: none; - border: 0; } - .range input::-webkit-slider-thumb:before { - /* what creates the colorful line on the left side of the slider */ - position: absolute; - top: 13px; - left: -2001px; - width: 2000px; - height: 2px; - background: #444; - content: ' '; } - .range input::-webkit-slider-thumb:after { - /* create a larger (but hidden) hit area */ - position: absolute; - top: -15px; - left: -15px; - padding: 30px; - content: ' '; } - .range input::-ms-fill-lower { - height: 2px; - background: #444; } - -.range { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; - padding: 2px 11px; } - .range.range-light input::-webkit-slider-thumb:before { - background: #ddd; } - .range.range-light input::-ms-fill-lower { - background: #ddd; } - .range.range-stable input::-webkit-slider-thumb:before { - background: #b2b2b2; } - .range.range-stable input::-ms-fill-lower { - background: #b2b2b2; } - .range.range-positive input::-webkit-slider-thumb:before { - background: #387ef5; } - .range.range-positive input::-ms-fill-lower { - background: #387ef5; } - .range.range-calm input::-webkit-slider-thumb:before { - background: #11c1f3; } - .range.range-calm input::-ms-fill-lower { - background: #11c1f3; } - .range.range-balanced input::-webkit-slider-thumb:before { - background: #33cd5f; } - .range.range-balanced input::-ms-fill-lower { - background: #33cd5f; } - .range.range-assertive input::-webkit-slider-thumb:before { - background: #ef473a; } - .range.range-assertive input::-ms-fill-lower { - background: #ef473a; } - .range.range-energized input::-webkit-slider-thumb:before { - background: #ffc900; } - .range.range-energized input::-ms-fill-lower { - background: #ffc900; } - .range.range-royal input::-webkit-slider-thumb:before { - background: #886aea; } - .range.range-royal input::-ms-fill-lower { - background: #886aea; } - .range.range-dark input::-webkit-slider-thumb:before { - background: #444; } - .range.range-dark input::-ms-fill-lower { - background: #444; } - -.range .icon { - -webkit-box-flex: 0; - -webkit-flex: 0; - -moz-box-flex: 0; - -moz-flex: 0; - -ms-flex: 0; - flex: 0; - display: block; - min-width: 24px; - text-align: center; - font-size: 24px; } - -.range input { - -webkit-box-flex: 1; - -webkit-flex: 1; - -moz-box-flex: 1; - -moz-flex: 1; - -ms-flex: 1; - flex: 1; - display: block; - margin-right: 10px; - margin-left: 10px; } - -.range-label { - -webkit-box-flex: 0; - -webkit-flex: 0 0 auto; - -moz-box-flex: 0; - -moz-flex: 0 0 auto; - -ms-flex: 0 0 auto; - flex: 0 0 auto; - display: block; - white-space: nowrap; } - -.range-label:first-child { - padding-left: 5px; } - -.range input + .range-label { - padding-right: 5px; - padding-left: 0; } - -.platform-windowsphone .range input { - height: auto; } - -/** - * Select - * -------------------------------------------------- - */ -.item-select { - position: relative; } - .item-select select { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; - position: absolute; - top: 0; - bottom: 0; - right: 0; - padding: 0 48px 0 16px; - max-width: 65%; - border: none; - background: #fff; - color: #333; - text-indent: .01px; - text-overflow: ''; - white-space: nowrap; - font-size: 14px; - cursor: pointer; - direction: rtl; } - .item-select select::-ms-expand { - display: none; } - .item-select option { - direction: ltr; } - .item-select:after { - position: absolute; - top: 50%; - right: 16px; - margin-top: -3px; - width: 0; - height: 0; - border-top: 5px solid; - border-right: 5px solid transparent; - border-left: 5px solid transparent; - color: #999; - content: ""; - pointer-events: none; } - .item-select.item-light select { - background: #fff; - color: #444; } - .item-select.item-stable select { - background: #f8f8f8; - color: #444; } - .item-select.item-stable:after, .item-select.item-stable .input-label { - color: #666666; } - .item-select.item-positive select { - background: #387ef5; - color: #fff; } - .item-select.item-positive:after, .item-select.item-positive .input-label { - color: #fff; } - .item-select.item-calm select { - background: #11c1f3; - color: #fff; } - .item-select.item-calm:after, .item-select.item-calm .input-label { - color: #fff; } - .item-select.item-assertive select { - background: #ef473a; - color: #fff; } - .item-select.item-assertive:after, .item-select.item-assertive .input-label { - color: #fff; } - .item-select.item-balanced select { - background: #33cd5f; - color: #fff; } - .item-select.item-balanced:after, .item-select.item-balanced .input-label { - color: #fff; } - .item-select.item-energized select { - background: #ffc900; - color: #fff; } - .item-select.item-energized:after, .item-select.item-energized .input-label { - color: #fff; } - .item-select.item-royal select { - background: #886aea; - color: #fff; } - .item-select.item-royal:after, .item-select.item-royal .input-label { - color: #fff; } - .item-select.item-dark select { - background: #444; - color: #fff; } - .item-select.item-dark:after, .item-select.item-dark .input-label { - color: #fff; } - -select[multiple], select[size] { - height: auto; } - -/** - * Progress - * -------------------------------------------------- - */ -progress { - display: block; - margin: 15px auto; - width: 100%; } - -/** - * Buttons - * -------------------------------------------------- - */ -.button { - border-color: transparent; - background-color: #f8f8f8; - color: #444; - position: relative; - display: inline-block; - margin: 0; - padding: 0 12px; - min-width: 52px; - min-height: 47px; - border-width: 1px; - border-style: solid; - border-radius: 4px; - vertical-align: top; - text-align: center; - text-overflow: ellipsis; - font-size: 16px; - line-height: 42px; - cursor: pointer; } - .button:hover { - color: #444; - text-decoration: none; } - .button.active, .button.activated { - border-color: #a2a2a2; - background-color: #e5e5e5; } - .button:after { - position: absolute; - top: -6px; - right: -6px; - bottom: -6px; - left: -6px; - content: ' '; } - .button .icon { - vertical-align: top; - pointer-events: none; } - .button .icon:before, .button.icon:before, .button.icon-left:before, .button.icon-right:before { - display: inline-block; - padding: 0 0 1px 0; - vertical-align: inherit; - font-size: 24px; - line-height: 41px; - pointer-events: none; } - .button.icon-left:before { - float: left; - padding-right: .2em; - padding-left: 0; } - .button.icon-right:before { - float: right; - padding-right: 0; - padding-left: .2em; } - .button.button-block, .button.button-full { - margin-top: 10px; - margin-bottom: 10px; } - .button.button-light { - border-color: transparent; - background-color: #fff; - color: #444; } - .button.button-light:hover { - color: #444; - text-decoration: none; } - .button.button-light.active, .button.button-light.activated { - border-color: #a2a2a2; - background-color: #fafafa; } - .button.button-light.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #ddd; } - .button.button-light.button-icon { - border-color: transparent; - background: none; } - .button.button-light.button-outline { - border-color: #ddd; - background: transparent; - color: #ddd; } - .button.button-light.button-outline.active, .button.button-light.button-outline.activated { - background-color: #ddd; - box-shadow: none; - color: #fff; } - .button.button-stable { - border-color: transparent; - background-color: #f8f8f8; - color: #444; } - .button.button-stable:hover { - color: #444; - text-decoration: none; } - .button.button-stable.active, .button.button-stable.activated { - border-color: #a2a2a2; - background-color: #e5e5e5; } - .button.button-stable.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #b2b2b2; } - .button.button-stable.button-icon { - border-color: transparent; - background: none; } - .button.button-stable.button-outline { - border-color: #b2b2b2; - background: transparent; - color: #b2b2b2; } - .button.button-stable.button-outline.active, .button.button-stable.button-outline.activated { - background-color: #b2b2b2; - box-shadow: none; - color: #fff; } - .button.button-positive { - border-color: transparent; - background-color: #387ef5; - color: #fff; } - .button.button-positive:hover { - color: #fff; - text-decoration: none; } - .button.button-positive.active, .button.button-positive.activated { - border-color: #a2a2a2; - background-color: #0c60ee; } - .button.button-positive.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #387ef5; } - .button.button-positive.button-icon { - border-color: transparent; - background: none; } - .button.button-positive.button-outline { - border-color: #387ef5; - background: transparent; - color: #387ef5; } - .button.button-positive.button-outline.active, .button.button-positive.button-outline.activated { - background-color: #387ef5; - box-shadow: none; - color: #fff; } - .button.button-calm { - border-color: transparent; - background-color: #11c1f3; - color: #fff; } - .button.button-calm:hover { - color: #fff; - text-decoration: none; } - .button.button-calm.active, .button.button-calm.activated { - border-color: #a2a2a2; - background-color: #0a9dc7; } - .button.button-calm.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #11c1f3; } - .button.button-calm.button-icon { - border-color: transparent; - background: none; } - .button.button-calm.button-outline { - border-color: #11c1f3; - background: transparent; - color: #11c1f3; } - .button.button-calm.button-outline.active, .button.button-calm.button-outline.activated { - background-color: #11c1f3; - box-shadow: none; - color: #fff; } - .button.button-assertive { - border-color: transparent; - background-color: #ef473a; - color: #fff; } - .button.button-assertive:hover { - color: #fff; - text-decoration: none; } - .button.button-assertive.active, .button.button-assertive.activated { - border-color: #a2a2a2; - background-color: #e42112; } - .button.button-assertive.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #ef473a; } - .button.button-assertive.button-icon { - border-color: transparent; - background: none; } - .button.button-assertive.button-outline { - border-color: #ef473a; - background: transparent; - color: #ef473a; } - .button.button-assertive.button-outline.active, .button.button-assertive.button-outline.activated { - background-color: #ef473a; - box-shadow: none; - color: #fff; } - .button.button-balanced { - border-color: transparent; - background-color: #33cd5f; - color: #fff; } - .button.button-balanced:hover { - color: #fff; - text-decoration: none; } - .button.button-balanced.active, .button.button-balanced.activated { - border-color: #a2a2a2; - background-color: #28a54c; } - .button.button-balanced.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #33cd5f; } - .button.button-balanced.button-icon { - border-color: transparent; - background: none; } - .button.button-balanced.button-outline { - border-color: #33cd5f; - background: transparent; - color: #33cd5f; } - .button.button-balanced.button-outline.active, .button.button-balanced.button-outline.activated { - background-color: #33cd5f; - box-shadow: none; - color: #fff; } - .button.button-energized { - border-color: transparent; - background-color: #ffc900; - color: #fff; } - .button.button-energized:hover { - color: #fff; - text-decoration: none; } - .button.button-energized.active, .button.button-energized.activated { - border-color: #a2a2a2; - background-color: #e6b500; } - .button.button-energized.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #ffc900; } - .button.button-energized.button-icon { - border-color: transparent; - background: none; } - .button.button-energized.button-outline { - border-color: #ffc900; - background: transparent; - color: #ffc900; } - .button.button-energized.button-outline.active, .button.button-energized.button-outline.activated { - background-color: #ffc900; - box-shadow: none; - color: #fff; } - .button.button-royal { - border-color: transparent; - background-color: #886aea; - color: #fff; } - .button.button-royal:hover { - color: #fff; - text-decoration: none; } - .button.button-royal.active, .button.button-royal.activated { - border-color: #a2a2a2; - background-color: #6b46e5; } - .button.button-royal.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #886aea; } - .button.button-royal.button-icon { - border-color: transparent; - background: none; } - .button.button-royal.button-outline { - border-color: #886aea; - background: transparent; - color: #886aea; } - .button.button-royal.button-outline.active, .button.button-royal.button-outline.activated { - background-color: #886aea; - box-shadow: none; - color: #fff; } - .button.button-dark { - border-color: transparent; - background-color: #444; - color: #fff; } - .button.button-dark:hover { - color: #fff; - text-decoration: none; } - .button.button-dark.active, .button.button-dark.activated { - border-color: #a2a2a2; - background-color: #262626; } - .button.button-dark.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: #444; } - .button.button-dark.button-icon { - border-color: transparent; - background: none; } - .button.button-dark.button-outline { - border-color: #444; - background: transparent; - color: #444; } - .button.button-dark.button-outline.active, .button.button-dark.button-outline.activated { - background-color: #444; - box-shadow: none; - color: #fff; } - -.button-small { - padding: 2px 4px 1px; - min-width: 28px; - min-height: 30px; - font-size: 12px; - line-height: 26px; } - .button-small .icon:before, .button-small.icon:before, .button-small.icon-left:before, .button-small.icon-right:before { - font-size: 16px; - line-height: 19px; - margin-top: 3px; } - -.button-large { - padding: 0 16px; - min-width: 68px; - min-height: 59px; - font-size: 20px; - line-height: 53px; } - .button-large .icon:before, .button-large.icon:before, .button-large.icon-left:before, .button-large.icon-right:before { - padding-bottom: 2px; - font-size: 32px; - line-height: 51px; } - -.button-icon { - -webkit-transition: opacity 0.1s; - transition: opacity 0.1s; - padding: 0 6px; - min-width: initial; - border-color: transparent; - background: none; } - .button-icon.button.active, .button-icon.button.activated { - border-color: transparent; - background: none; - box-shadow: none; - opacity: 0.3; } - .button-icon .icon:before, .button-icon.icon:before { - font-size: 32px; } - -.button-clear { - -webkit-transition: opacity 0.1s; - transition: opacity 0.1s; - padding: 0 6px; - max-height: 42px; - border-color: transparent; - background: none; - box-shadow: none; } - .button-clear.button-clear { - border-color: transparent; - background: none; - box-shadow: none; - color: transparent; } - .button-clear.button-icon { - border-color: transparent; - background: none; } - .button-clear.active, .button-clear.activated { - opacity: 0.3; } - -.button-outline { - -webkit-transition: opacity 0.1s; - transition: opacity 0.1s; - background: none; - box-shadow: none; } - .button-outline.button-outline { - border-color: transparent; - background: transparent; - color: transparent; } - .button-outline.button-outline.active, .button-outline.button-outline.activated { - background-color: transparent; - box-shadow: none; - color: #fff; } - -.padding > .button.button-block:first-child { - margin-top: 0; } - -.button-block { - display: block; - clear: both; } - .button-block:after { - clear: both; } - -.button-full, -.button-full > .button { - display: block; - margin-right: 0; - margin-left: 0; - border-right-width: 0; - border-left-width: 0; - border-radius: 0; } - -button.button-block, -button.button-full, -.button-full > button.button, -input.button.button-block { - width: 100%; } - -a.button { - text-decoration: none; } - a.button .icon:before, a.button.icon:before, a.button.icon-left:before, a.button.icon-right:before { - margin-top: 2px; } - -.button.disabled, -.button[disabled] { - opacity: .4; - cursor: default !important; - pointer-events: none; } - -/** - * Button Bar - * -------------------------------------------------- - */ -.button-bar { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - -webkit-box-flex: 1; - -webkit-flex: 1; - -moz-box-flex: 1; - -moz-flex: 1; - -ms-flex: 1; - flex: 1; - width: 100%; } - .button-bar.button-bar-inline { - display: block; - width: auto; - *zoom: 1; } - .button-bar.button-bar-inline:before, .button-bar.button-bar-inline:after { - display: table; - content: ""; - line-height: 0; } - .button-bar.button-bar-inline:after { - clear: both; } - .button-bar.button-bar-inline > .button { - width: auto; - display: inline-block; - float: left; } - .button-bar.bar-light > .button { - border-color: #ddd; } - .button-bar.bar-stable > .button { - border-color: #b2b2b2; } - .button-bar.bar-positive > .button { - border-color: #0c60ee; } - .button-bar.bar-calm > .button { - border-color: #0a9dc7; } - .button-bar.bar-assertive > .button { - border-color: #e42112; } - .button-bar.bar-balanced > .button { - border-color: #28a54c; } - .button-bar.bar-energized > .button { - border-color: #e6b500; } - .button-bar.bar-royal > .button { - border-color: #6b46e5; } - .button-bar.bar-dark > .button { - border-color: #111; } - -.button-bar > .button { - -webkit-box-flex: 1; - -webkit-flex: 1; - -moz-box-flex: 1; - -moz-flex: 1; - -ms-flex: 1; - flex: 1; - display: block; - overflow: hidden; - padding: 0 16px; - width: 0; - border-width: 1px 0px 1px 1px; - border-radius: 0; - text-align: center; - text-overflow: ellipsis; - white-space: nowrap; } - .button-bar > .button:before, - .button-bar > .button .icon:before { - line-height: 44px; } - .button-bar > .button:first-child { - border-radius: 4px 0px 0px 4px; } - .button-bar > .button:last-child { - border-right-width: 1px; - border-radius: 0px 4px 4px 0px; } - .button-bar > .button:only-child { - border-radius: 4px; } - -.button-bar > .button-small:before, -.button-bar > .button-small .icon:before { - line-height: 28px; } - -/** - * Grid - * -------------------------------------------------- - * Using flexbox for the grid, inspired by Philip Walton: - * http://philipwalton.github.io/solved-by-flexbox/demos/grids/ - * By default each .col within a .row will evenly take up - * available width, and the height of each .col with take - * up the height of the tallest .col in the same .row. - */ -.row { - display: -webkit-box; - display: -webkit-flex; - display: -moz-box; - display: -moz-flex; - display: -ms-flexbox; - display: flex; - padding: 5px; - width: 100%; } - -.row-wrap { - -webkit-flex-wrap: wrap; - -moz-flex-wrap: wrap; - -ms-flex-wrap: wrap; - flex-wrap: wrap; } - -.row-no-padding { - padding: 0; } - .row-no-padding > .col { - padding: 0; } - -.row + .row { - margin-top: -5px; - padding-top: 0; } - -.col { - -webkit-box-flex: 1; - -webkit-flex: 1; - -moz-box-flex: 1; - -moz-flex: 1; - -ms-flex: 1; - flex: 1; - display: block; - padding: 5px; - width: 100%; } - -/* Vertically Align Columns */ -/* .row-* vertically aligns every .col in the .row */ -.row-top { - -webkit-box-align: start; - -ms-flex-align: start; - -webkit-align-items: flex-start; - -moz-align-items: flex-start; - align-items: flex-start; } - -.row-bottom { - -webkit-box-align: end; - -ms-flex-align: end; - -webkit-align-items: flex-end; - -moz-align-items: flex-end; - align-items: flex-end; } - -.row-center { - -webkit-box-align: center; - -ms-flex-align: center; - -webkit-align-items: center; - -moz-align-items: center; - align-items: center; } - -.row-stretch { - -webkit-box-align: stretch; - -ms-flex-align: stretch; - -webkit-align-items: stretch; - -moz-align-items: stretch; - align-items: stretch; } - -.row-baseline { - -webkit-box-align: baseline; - -ms-flex-align: baseline; - -webkit-align-items: baseline; - -moz-align-items: baseline; - align-items: baseline; } - -/* .col-* vertically aligns an individual .col */ -.col-top { - -webkit-align-self: flex-start; - -moz-align-self: flex-start; - -ms-flex-item-align: start; - align-self: flex-start; } - -.col-bottom { - -webkit-align-self: flex-end; - -moz-align-self: flex-end; - -ms-flex-item-align: end; - align-self: flex-end; } - -.col-center { - -webkit-align-self: center; - -moz-align-self: center; - -ms-flex-item-align: center; - align-self: center; } - -/* Column Offsets */ -.col-offset-10 { - margin-left: 10%; } - -.col-offset-20 { - margin-left: 20%; } - -.col-offset-25 { - margin-left: 25%; } - -.col-offset-33, .col-offset-34 { - margin-left: 33.3333%; } - -.col-offset-50 { - margin-left: 50%; } - -.col-offset-66, .col-offset-67 { - margin-left: 66.6666%; } - -.col-offset-75 { - margin-left: 75%; } - -.col-offset-80 { - margin-left: 80%; } - -.col-offset-90 { - margin-left: 90%; } - -/* Explicit Column Percent Sizes */ -/* By default each grid column will evenly distribute */ -/* across the grid. However, you can specify individual */ -/* columns to take up a certain size of the available area */ -.col-10 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 10%; - -moz-box-flex: 0; - -moz-flex: 0 0 10%; - -ms-flex: 0 0 10%; - flex: 0 0 10%; - max-width: 10%; } - -.col-20 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 20%; - -moz-box-flex: 0; - -moz-flex: 0 0 20%; - -ms-flex: 0 0 20%; - flex: 0 0 20%; - max-width: 20%; } - -.col-25 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 25%; - -moz-box-flex: 0; - -moz-flex: 0 0 25%; - -ms-flex: 0 0 25%; - flex: 0 0 25%; - max-width: 25%; } - -.col-33, .col-34 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 33.3333%; - -moz-box-flex: 0; - -moz-flex: 0 0 33.3333%; - -ms-flex: 0 0 33.3333%; - flex: 0 0 33.3333%; - max-width: 33.3333%; } - -.col-40 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 40%; - -moz-box-flex: 0; - -moz-flex: 0 0 40%; - -ms-flex: 0 0 40%; - flex: 0 0 40%; - max-width: 40%; } - -.col-50 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 50%; - -moz-box-flex: 0; - -moz-flex: 0 0 50%; - -ms-flex: 0 0 50%; - flex: 0 0 50%; - max-width: 50%; } - -.col-60 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 60%; - -moz-box-flex: 0; - -moz-flex: 0 0 60%; - -ms-flex: 0 0 60%; - flex: 0 0 60%; - max-width: 60%; } - -.col-66, .col-67 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 66.6666%; - -moz-box-flex: 0; - -moz-flex: 0 0 66.6666%; - -ms-flex: 0 0 66.6666%; - flex: 0 0 66.6666%; - max-width: 66.6666%; } - -.col-75 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 75%; - -moz-box-flex: 0; - -moz-flex: 0 0 75%; - -ms-flex: 0 0 75%; - flex: 0 0 75%; - max-width: 75%; } - -.col-80 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 80%; - -moz-box-flex: 0; - -moz-flex: 0 0 80%; - -ms-flex: 0 0 80%; - flex: 0 0 80%; - max-width: 80%; } - -.col-90 { - -webkit-box-flex: 0; - -webkit-flex: 0 0 90%; - -moz-box-flex: 0; - -moz-flex: 0 0 90%; - -ms-flex: 0 0 90%; - flex: 0 0 90%; - max-width: 90%; } - -/* Responsive Grid Classes */ -/* Adding a class of responsive-X to a row */ -/* will trigger the flex-direction to */ -/* change to column and add some margin */ -/* to any columns in the row for clearity */ -@media (max-width: 567px) { - .responsive-sm { - -webkit-box-direction: normal; - -moz-box-direction: normal; - -webkit-box-orient: vertical; - -moz-box-orient: vertical; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; } - .responsive-sm .col, .responsive-sm .col-10, .responsive-sm .col-20, .responsive-sm .col-25, .responsive-sm .col-33, .responsive-sm .col-34, .responsive-sm .col-50, .responsive-sm .col-66, .responsive-sm .col-67, .responsive-sm .col-75, .responsive-sm .col-80, .responsive-sm .col-90 { - -webkit-box-flex: 1; - -webkit-flex: 1; - -moz-box-flex: 1; - -moz-flex: 1; - -ms-flex: 1; - flex: 1; - margin-bottom: 15px; - margin-left: 0; - max-width: 100%; - width: 100%; } } - -@media (max-width: 767px) { - .responsive-md { - -webkit-box-direction: normal; - -moz-box-direction: normal; - -webkit-box-orient: vertical; - -moz-box-orient: vertical; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; } - .responsive-md .col, .responsive-md .col-10, .responsive-md .col-20, .responsive-md .col-25, .responsive-md .col-33, .responsive-md .col-34, .responsive-md .col-50, .responsive-md .col-66, .responsive-md .col-67, .responsive-md .col-75, .responsive-md .col-80, .responsive-md .col-90 { - -webkit-box-flex: 1; - -webkit-flex: 1; - -moz-box-flex: 1; - -moz-flex: 1; - -ms-flex: 1; - flex: 1; - margin-bottom: 15px; - margin-left: 0; - max-width: 100%; - width: 100%; } } - -@media (max-width: 1023px) { - .responsive-lg { - -webkit-box-direction: normal; - -moz-box-direction: normal; - -webkit-box-orient: vertical; - -moz-box-orient: vertical; - -webkit-flex-direction: column; - -ms-flex-direction: column; - flex-direction: column; } - .responsive-lg .col, .responsive-lg .col-10, .responsive-lg .col-20, .responsive-lg .col-25, .responsive-lg .col-33, .responsive-lg .col-34, .responsive-lg .col-50, .responsive-lg .col-66, .responsive-lg .col-67, .responsive-lg .col-75, .responsive-lg .col-80, .responsive-lg .col-90 { - -webkit-box-flex: 1; - -webkit-flex: 1; - -moz-box-flex: 1; - -moz-flex: 1; - -ms-flex: 1; - flex: 1; - margin-bottom: 15px; - margin-left: 0; - max-width: 100%; - width: 100%; } } - -/** - * Utility Classes - * -------------------------------------------------- - */ -.hide { - display: none; } - -.opacity-hide { - opacity: 0; } - -.grade-b .opacity-hide, -.grade-c .opacity-hide { - opacity: 1; - display: none; } - -.show { - display: block; } - -.opacity-show { - opacity: 1; } - -.invisible { - visibility: hidden; } - -.keyboard-open .hide-on-keyboard-open { - display: none; } - -.keyboard-open .tabs.hide-on-keyboard-open + .pane .has-tabs, -.keyboard-open .bar-footer.hide-on-keyboard-open + .pane .has-footer { - bottom: 0; } - -.inline { - display: inline-block; } - -.disable-pointer-events { - pointer-events: none; } - -.enable-pointer-events { - pointer-events: auto; } - -.disable-user-behavior { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - -webkit-touch-callout: none; - -webkit-tap-highlight-color: transparent; - -webkit-tap-highlight-color: transparent; - -webkit-user-drag: none; - -ms-touch-action: none; - -ms-content-zooming: none; } - -.click-block { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - opacity: 0; - z-index: 99999; - -webkit-transform: translate3d(0, 0, 0); - transform: translate3d(0, 0, 0); - overflow: hidden; } - -.click-block-hide { - -webkit-transform: translate3d(-9999px, 0, 0); - transform: translate3d(-9999px, 0, 0); } - -.no-resize { - resize: none; } - -.block { - display: block; - clear: both; } - .block:after { - display: block; - visibility: hidden; - clear: both; - height: 0; - content: "."; } - -.full-image { - width: 100%; } - -.clearfix { - *zoom: 1; } - .clearfix:before, .clearfix:after { - display: table; - content: ""; - line-height: 0; } - .clearfix:after { - clear: both; } - -/** - * Content Padding - * -------------------------------------------------- - */ -.padding { - padding: 10px; } - -.padding-top, -.padding-vertical { - padding-top: 10px; } - -.padding-right, -.padding-horizontal { - padding-right: 10px; } - -.padding-bottom, -.padding-vertical { - padding-bottom: 10px; } - -.padding-left, -.padding-horizontal { - padding-left: 10px; } - -/** - * Scrollable iFrames - * -------------------------------------------------- - */ -.iframe-wrapper { - position: fixed; - -webkit-overflow-scrolling: touch; - overflow: scroll; } - .iframe-wrapper iframe { - height: 100%; - width: 100%; } - -/** - * Rounded - * -------------------------------------------------- - */ -.rounded { - border-radius: 4px; } - -/** - * Utility Colors - * -------------------------------------------------- - * Utility colors are added to help set a naming convention. You'll - * notice we purposely do not use words like "red" or "blue", but - * instead have colors which represent an emotion or generic theme. - */ -.light, a.light { - color: #fff; } - -.light-bg { - background-color: #fff; } - -.light-border { - border-color: #ddd; } - -.stable, a.stable { - color: #f8f8f8; } - -.stable-bg { - background-color: #f8f8f8; } - -.stable-border { - border-color: #b2b2b2; } - -.positive, a.positive { - color: #387ef5; } - -.positive-bg { - background-color: #387ef5; } - -.positive-border { - border-color: #0c60ee; } - -.calm, a.calm { - color: #11c1f3; } - -.calm-bg { - background-color: #11c1f3; } - -.calm-border { - border-color: #0a9dc7; } - -.assertive, a.assertive { - color: #ef473a; } - -.assertive-bg { - background-color: #ef473a; } - -.assertive-border { - border-color: #e42112; } - -.balanced, a.balanced { - color: #33cd5f; } - -.balanced-bg { - background-color: #33cd5f; } - -.balanced-border { - border-color: #28a54c; } - -.energized, a.energized { - color: #ffc900; } - -.energized-bg { - background-color: #ffc900; } - -.energized-border { - border-color: #e6b500; } - -.royal, a.royal { - color: #886aea; } - -.royal-bg { - background-color: #886aea; } - -.royal-border { - border-color: #6b46e5; } - -.dark, a.dark { - color: #444; } - -.dark-bg { - background-color: #444; } - -.dark-border { - border-color: #111; } - -[collection-repeat] { - /* Position is set by transforms */ - left: 0 !important; - top: 0 !important; - position: absolute !important; - z-index: 1; } - -.collection-repeat-container { - position: relative; - z-index: 1; } - -.collection-repeat-after-container { - z-index: 0; - display: block; - /* when scrolling horizontally, make sure the after container doesn't take up 100% width */ } - .collection-repeat-after-container.horizontal { - display: inline-block; } - -[ng\:cloak], [ng-cloak], [data-ng-cloak], [x-ng-cloak], .ng-cloak, -.x-ng-cloak, .ng-hide:not(.ng-hide-animate) { - display: none !important; } - -/** - * Platform - * -------------------------------------------------- - * Platform specific tweaks - */ -.platform-ios.platform-cordova:not(.fullscreen) .bar-header:not(.bar-subheader) { - height: 64px; } - .platform-ios.platform-cordova:not(.fullscreen) .bar-header:not(.bar-subheader).item-input-inset .item-input-wrapper { - margin-top: 19px !important; } - .platform-ios.platform-cordova:not(.fullscreen) .bar-header:not(.bar-subheader) > * { - margin-top: 20px; } - -.platform-ios.platform-cordova:not(.fullscreen) .tabs-top > .tabs, -.platform-ios.platform-cordova:not(.fullscreen) .tabs.tabs-top { - top: 64px; } - -.platform-ios.platform-cordova:not(.fullscreen) .has-header, -.platform-ios.platform-cordova:not(.fullscreen) .bar-subheader { - top: 64px; } - -.platform-ios.platform-cordova:not(.fullscreen) .has-subheader { - top: 108px; } - -.platform-ios.platform-cordova:not(.fullscreen) .has-header.has-tabs-top { - top: 113px; } - -.platform-ios.platform-cordova:not(.fullscreen) .has-header.has-subheader.has-tabs-top { - top: 157px; } - -.platform-ios.platform-cordova .popover .bar-header:not(.bar-subheader) { - height: 44px; } - .platform-ios.platform-cordova .popover .bar-header:not(.bar-subheader).item-input-inset .item-input-wrapper { - margin-top: -1px; } - .platform-ios.platform-cordova .popover .bar-header:not(.bar-subheader) > * { - margin-top: 0; } - -.platform-ios.platform-cordova .popover .has-header, -.platform-ios.platform-cordova .popover .bar-subheader { - top: 44px; } - -.platform-ios.platform-cordova .popover .has-subheader { - top: 88px; } - -.platform-ios.platform-cordova.status-bar-hide { - margin-bottom: 20px; } - -@media (orientation: landscape) { - .platform-ios.platform-browser.platform-ipad { - position: fixed; } } - -.platform-c:not(.enable-transitions) * { - -webkit-transition: none !important; - transition: none !important; } - -.slide-in-up { - -webkit-transform: translate3d(0, 100%, 0); - transform: translate3d(0, 100%, 0); } - -.slide-in-up.ng-enter, -.slide-in-up > .ng-enter { - -webkit-transition: all cubic-bezier(0.1, 0.7, 0.1, 1) 400ms; - transition: all cubic-bezier(0.1, 0.7, 0.1, 1) 400ms; } - -.slide-in-up.ng-enter-active, -.slide-in-up > .ng-enter-active { - -webkit-transform: translate3d(0, 0, 0); - transform: translate3d(0, 0, 0); } - -.slide-in-up.ng-leave, -.slide-in-up > .ng-leave { - -webkit-transition: all ease-in-out 250ms; - transition: all ease-in-out 250ms; } - -@-webkit-keyframes scaleOut { - from { - -webkit-transform: scale(1); - opacity: 1; } - to { - -webkit-transform: scale(0.8); - opacity: 0; } } - -@keyframes scaleOut { - from { - transform: scale(1); - opacity: 1; } - to { - transform: scale(0.8); - opacity: 0; } } - -@-webkit-keyframes superScaleIn { - from { - -webkit-transform: scale(1.2); - opacity: 0; } - to { - -webkit-transform: scale(1); - opacity: 1; } } - -@keyframes superScaleIn { - from { - transform: scale(1.2); - opacity: 0; } - to { - transform: scale(1); - opacity: 1; } } - -[nav-view-transition="ios"] [nav-view="entering"], -[nav-view-transition="ios"] [nav-view="leaving"] { - -webkit-transition-duration: 500ms; - transition-duration: 500ms; - -webkit-transition-timing-function: cubic-bezier(0.36, 0.66, 0.04, 1); - transition-timing-function: cubic-bezier(0.36, 0.66, 0.04, 1); - -webkit-transition-property: opacity, -webkit-transform, box-shadow; - transition-property: opacity, transform, box-shadow; } - -[nav-view-transition="ios"][nav-view-direction="forward"], [nav-view-transition="ios"][nav-view-direction="back"] { - background-color: #000; } - -[nav-view-transition="ios"] [nav-view="active"], -[nav-view-transition="ios"][nav-view-direction="forward"] [nav-view="entering"], -[nav-view-transition="ios"][nav-view-direction="back"] [nav-view="leaving"] { - z-index: 3; } - -[nav-view-transition="ios"][nav-view-direction="back"] [nav-view="entering"], -[nav-view-transition="ios"][nav-view-direction="forward"] [nav-view="leaving"] { - z-index: 2; } - -[nav-bar-transition="ios"] .title, -[nav-bar-transition="ios"] .buttons, -[nav-bar-transition="ios"] .back-text { - -webkit-transition-duration: 500ms; - transition-duration: 500ms; - -webkit-transition-timing-function: cubic-bezier(0.36, 0.66, 0.04, 1); - transition-timing-function: cubic-bezier(0.36, 0.66, 0.04, 1); - -webkit-transition-property: opacity, -webkit-transform; - transition-property: opacity, transform; } - -[nav-bar-transition="ios"] [nav-bar="active"], -[nav-bar-transition="ios"] [nav-bar="entering"] { - z-index: 10; } - [nav-bar-transition="ios"] [nav-bar="active"] .bar, - [nav-bar-transition="ios"] [nav-bar="entering"] .bar { - background: transparent; } - -[nav-bar-transition="ios"] [nav-bar="cached"] { - display: block; } - [nav-bar-transition="ios"] [nav-bar="cached"] .header-item { - display: none; } - -[nav-view-transition="android"] [nav-view="entering"], -[nav-view-transition="android"] [nav-view="leaving"] { - -webkit-transition-duration: 200ms; - transition-duration: 200ms; - -webkit-transition-timing-function: cubic-bezier(0.4, 0.6, 0.2, 1); - transition-timing-function: cubic-bezier(0.4, 0.6, 0.2, 1); - -webkit-transition-property: -webkit-transform; - transition-property: transform; } - -[nav-view-transition="android"] [nav-view="active"], -[nav-view-transition="android"][nav-view-direction="forward"] [nav-view="entering"], -[nav-view-transition="android"][nav-view-direction="back"] [nav-view="leaving"] { - z-index: 3; } - -[nav-view-transition="android"][nav-view-direction="back"] [nav-view="entering"], -[nav-view-transition="android"][nav-view-direction="forward"] [nav-view="leaving"] { - z-index: 2; } - -[nav-bar-transition="android"] .title, -[nav-bar-transition="android"] .buttons { - -webkit-transition-duration: 200ms; - transition-duration: 200ms; - -webkit-transition-timing-function: cubic-bezier(0.4, 0.6, 0.2, 1); - transition-timing-function: cubic-bezier(0.4, 0.6, 0.2, 1); - -webkit-transition-property: opacity; - transition-property: opacity; } - -[nav-bar-transition="android"] [nav-bar="active"], -[nav-bar-transition="android"] [nav-bar="entering"] { - z-index: 10; } - [nav-bar-transition="android"] [nav-bar="active"] .bar, - [nav-bar-transition="android"] [nav-bar="entering"] .bar { - background: transparent; } - -[nav-bar-transition="android"] [nav-bar="cached"] { - display: block; } - [nav-bar-transition="android"] [nav-bar="cached"] .header-item { - display: none; } - -[nav-swipe="fast"] [nav-view], -[nav-swipe="fast"] .title, -[nav-swipe="fast"] .buttons, -[nav-swipe="fast"] .back-text { - -webkit-transition-duration: 50ms; - transition-duration: 50ms; - -webkit-transition-timing-function: linear; - transition-timing-function: linear; } - -[nav-swipe="slow"] [nav-view], -[nav-swipe="slow"] .title, -[nav-swipe="slow"] .buttons, -[nav-swipe="slow"] .back-text { - -webkit-transition-duration: 160ms; - transition-duration: 160ms; - -webkit-transition-timing-function: linear; - transition-timing-function: linear; } - -[nav-view="cached"], -[nav-bar="cached"] { - display: none; } - -[nav-view="stage"] { - opacity: 0; - -webkit-transition-duration: 0; - transition-duration: 0; } - -[nav-bar="stage"] .title, -[nav-bar="stage"] .buttons, -[nav-bar="stage"] .back-text { - position: absolute; - opacity: 0; - -webkit-transition-duration: 0s; - transition-duration: 0s; } diff --git a/www/manual_lib/ionic/css/ionic.min.css b/www/manual_lib/ionic/css/ionic.min.css deleted file mode 100644 index 9159a0901..000000000 --- a/www/manual_lib/ionic/css/ionic.min.css +++ /dev/null @@ -1,23 +0,0 @@ -@charset "UTF-8";/*! - * Copyright 2015 Drifty Co. - * http://drifty.com/ - * - * Ionic, v1.3.3 - * A powerful HTML5 mobile app framework. - * http://ionicframework.com/ - * - * By @maxlynch, @benjsperry, @adamdbradley <3 - * - * Licensed under the MIT license. Please see LICENSE for more information. - * - *//*! - Ionicons, v2.0.1 - Created by Ben Sperry for the Ionic Framework, http://ionicons.com/ - https://twitter.com/benjsperry https://twitter.com/ionicframework - MIT License: https://github.com/driftyco/ionicons - - Android-style icons originally built by Googleā€™s - Material Design Icons: https://github.com/google/material-design-icons - used under CC BY http://creativecommons.org/licenses/by/4.0/ - Modified icons to fit ioniconā€™s grid from original. -*/@font-face{font-family:Ionicons;src:url(../fonts/ionicons.eot?v=2.0.1);src:url(../fonts/ionicons.eot?v=2.0.1#iefix) format("embedded-opentype"),url(../fonts/ionicons.ttf?v=2.0.1) format("truetype"),url(../fonts/ionicons.woff?v=2.0.1) format("woff"),url(../fonts/ionicons.woff) format("woff"),url(../fonts/ionicons.svg?v=2.0.1#Ionicons) format("svg");font-weight:400;font-style:normal}.ion,.ion-alert-circled:before,.ion-alert:before,.ion-android-add-circle:before,.ion-android-add:before,.ion-android-alarm-clock:before,.ion-android-alert:before,.ion-android-apps:before,.ion-android-archive:before,.ion-android-arrow-back:before,.ion-android-arrow-down:before,.ion-android-arrow-dropdown-circle:before,.ion-android-arrow-dropdown:before,.ion-android-arrow-dropleft-circle:before,.ion-android-arrow-dropleft:before,.ion-android-arrow-dropright-circle:before,.ion-android-arrow-dropright:before,.ion-android-arrow-dropup-circle:before,.ion-android-arrow-dropup:before,.ion-android-arrow-forward:before,.ion-android-arrow-up:before,.ion-android-attach:before,.ion-android-bar:before,.ion-android-bicycle:before,.ion-android-boat:before,.ion-android-bookmark:before,.ion-android-bulb:before,.ion-android-bus:before,.ion-android-calendar:before,.ion-android-call:before,.ion-android-camera:before,.ion-android-cancel:before,.ion-android-car:before,.ion-android-cart:before,.ion-android-chat:before,.ion-android-checkbox-blank:before,.ion-android-checkbox-outline-blank:before,.ion-android-checkbox-outline:before,.ion-android-checkbox:before,.ion-android-checkmark-circle:before,.ion-android-clipboard:before,.ion-android-close:before,.ion-android-cloud-circle:before,.ion-android-cloud-done:before,.ion-android-cloud-outline:before,.ion-android-cloud:before,.ion-android-color-palette:before,.ion-android-compass:before,.ion-android-contact:before,.ion-android-contacts:before,.ion-android-contract:before,.ion-android-create:before,.ion-android-delete:before,.ion-android-desktop:before,.ion-android-document:before,.ion-android-done-all:before,.ion-android-done:before,.ion-android-download:before,.ion-android-drafts:before,.ion-android-exit:before,.ion-android-expand:before,.ion-android-favorite-outline:before,.ion-android-favorite:before,.ion-android-film:before,.ion-android-folder-open:before,.ion-android-folder:before,.ion-android-funnel:before,.ion-android-globe:before,.ion-android-hand:before,.ion-android-hangout:before,.ion-android-happy:before,.ion-android-home:before,.ion-android-image:before,.ion-android-laptop:before,.ion-android-list:before,.ion-android-locate:before,.ion-android-lock:before,.ion-android-mail:before,.ion-android-map:before,.ion-android-menu:before,.ion-android-microphone-off:before,.ion-android-microphone:before,.ion-android-more-horizontal:before,.ion-android-more-vertical:before,.ion-android-navigate:before,.ion-android-notifications-none:before,.ion-android-notifications-off:before,.ion-android-notifications:before,.ion-android-open:before,.ion-android-options:before,.ion-android-people:before,.ion-android-person-add:before,.ion-android-person:before,.ion-android-phone-landscape:before,.ion-android-phone-portrait:before,.ion-android-pin:before,.ion-android-plane:before,.ion-android-playstore:before,.ion-android-print:before,.ion-android-radio-button-off:before,.ion-android-radio-button-on:before,.ion-android-refresh:before,.ion-android-remove-circle:before,.ion-android-remove:before,.ion-android-restaurant:before,.ion-android-sad:before,.ion-android-search:before,.ion-android-send:before,.ion-android-settings:before,.ion-android-share-alt:before,.ion-android-share:before,.ion-android-star-half:before,.ion-android-star-outline:before,.ion-android-star:before,.ion-android-stopwatch:before,.ion-android-subway:before,.ion-android-sunny:before,.ion-android-sync:before,.ion-android-textsms:before,.ion-android-time:before,.ion-android-train:before,.ion-android-unlock:before,.ion-android-upload:before,.ion-android-volume-down:before,.ion-android-volume-mute:before,.ion-android-volume-off:before,.ion-android-volume-up:before,.ion-android-walk:before,.ion-android-warning:before,.ion-android-watch:before,.ion-android-wifi:before,.ion-aperture:before,.ion-archive:before,.ion-arrow-down-a:before,.ion-arrow-down-b:before,.ion-arrow-down-c:before,.ion-arrow-expand:before,.ion-arrow-graph-down-left:before,.ion-arrow-graph-down-right:before,.ion-arrow-graph-up-left:before,.ion-arrow-graph-up-right:before,.ion-arrow-left-a:before,.ion-arrow-left-b:before,.ion-arrow-left-c:before,.ion-arrow-move:before,.ion-arrow-resize:before,.ion-arrow-return-left:before,.ion-arrow-return-right:before,.ion-arrow-right-a:before,.ion-arrow-right-b:before,.ion-arrow-right-c:before,.ion-arrow-shrink:before,.ion-arrow-swap:before,.ion-arrow-up-a:before,.ion-arrow-up-b:before,.ion-arrow-up-c:before,.ion-asterisk:before,.ion-at:before,.ion-backspace-outline:before,.ion-backspace:before,.ion-bag:before,.ion-battery-charging:before,.ion-battery-empty:before,.ion-battery-full:before,.ion-battery-half:before,.ion-battery-low:before,.ion-beaker:before,.ion-beer:before,.ion-bluetooth:before,.ion-bonfire:before,.ion-bookmark:before,.ion-bowtie:before,.ion-briefcase:before,.ion-bug:before,.ion-calculator:before,.ion-calendar:before,.ion-camera:before,.ion-card:before,.ion-cash:before,.ion-chatbox-working:before,.ion-chatbox:before,.ion-chatboxes:before,.ion-chatbubble-working:before,.ion-chatbubble:before,.ion-chatbubbles:before,.ion-checkmark-circled:before,.ion-checkmark-round:before,.ion-checkmark:before,.ion-chevron-down:before,.ion-chevron-left:before,.ion-chevron-right:before,.ion-chevron-up:before,.ion-clipboard:before,.ion-clock:before,.ion-close-circled:before,.ion-close-round:before,.ion-close:before,.ion-closed-captioning:before,.ion-cloud:before,.ion-code-download:before,.ion-code-working:before,.ion-code:before,.ion-coffee:before,.ion-compass:before,.ion-compose:before,.ion-connection-bars:before,.ion-contrast:before,.ion-crop:before,.ion-cube:before,.ion-disc:before,.ion-document-text:before,.ion-document:before,.ion-drag:before,.ion-earth:before,.ion-easel:before,.ion-edit:before,.ion-egg:before,.ion-eject:before,.ion-email-unread:before,.ion-email:before,.ion-erlenmeyer-flask-bubbles:before,.ion-erlenmeyer-flask:before,.ion-eye-disabled:before,.ion-eye:before,.ion-female:before,.ion-filing:before,.ion-film-marker:before,.ion-fireball:before,.ion-flag:before,.ion-flame:before,.ion-flash-off:before,.ion-flash:before,.ion-folder:before,.ion-fork-repo:before,.ion-fork:before,.ion-forward:before,.ion-funnel:before,.ion-gear-a:before,.ion-gear-b:before,.ion-grid:before,.ion-hammer:before,.ion-happy-outline:before,.ion-happy:before,.ion-headphone:before,.ion-heart-broken:before,.ion-heart:before,.ion-help-buoy:before,.ion-help-circled:before,.ion-help:before,.ion-home:before,.ion-icecream:before,.ion-image:before,.ion-images:before,.ion-information-circled:before,.ion-information:before,.ion-ionic:before,.ion-ios-alarm-outline:before,.ion-ios-alarm:before,.ion-ios-albums-outline:before,.ion-ios-albums:before,.ion-ios-americanfootball-outline:before,.ion-ios-americanfootball:before,.ion-ios-analytics-outline:before,.ion-ios-analytics:before,.ion-ios-arrow-back:before,.ion-ios-arrow-down:before,.ion-ios-arrow-forward:before,.ion-ios-arrow-left:before,.ion-ios-arrow-right:before,.ion-ios-arrow-thin-down:before,.ion-ios-arrow-thin-left:before,.ion-ios-arrow-thin-right:before,.ion-ios-arrow-thin-up:before,.ion-ios-arrow-up:before,.ion-ios-at-outline:before,.ion-ios-at:before,.ion-ios-barcode-outline:before,.ion-ios-barcode:before,.ion-ios-baseball-outline:before,.ion-ios-baseball:before,.ion-ios-basketball-outline:before,.ion-ios-basketball:before,.ion-ios-bell-outline:before,.ion-ios-bell:before,.ion-ios-body-outline:before,.ion-ios-body:before,.ion-ios-bolt-outline:before,.ion-ios-bolt:before,.ion-ios-book-outline:before,.ion-ios-book:before,.ion-ios-bookmarks-outline:before,.ion-ios-bookmarks:before,.ion-ios-box-outline:before,.ion-ios-box:before,.ion-ios-briefcase-outline:before,.ion-ios-briefcase:before,.ion-ios-browsers-outline:before,.ion-ios-browsers:before,.ion-ios-calculator-outline:before,.ion-ios-calculator:before,.ion-ios-calendar-outline:before,.ion-ios-calendar:before,.ion-ios-camera-outline:before,.ion-ios-camera:before,.ion-ios-cart-outline:before,.ion-ios-cart:before,.ion-ios-chatboxes-outline:before,.ion-ios-chatboxes:before,.ion-ios-chatbubble-outline:before,.ion-ios-chatbubble:before,.ion-ios-checkmark-empty:before,.ion-ios-checkmark-outline:before,.ion-ios-checkmark:before,.ion-ios-circle-filled:before,.ion-ios-circle-outline:before,.ion-ios-clock-outline:before,.ion-ios-clock:before,.ion-ios-close-empty:before,.ion-ios-close-outline:before,.ion-ios-close:before,.ion-ios-cloud-download-outline:before,.ion-ios-cloud-download:before,.ion-ios-cloud-outline:before,.ion-ios-cloud-upload-outline:before,.ion-ios-cloud-upload:before,.ion-ios-cloud:before,.ion-ios-cloudy-night-outline:before,.ion-ios-cloudy-night:before,.ion-ios-cloudy-outline:before,.ion-ios-cloudy:before,.ion-ios-cog-outline:before,.ion-ios-cog:before,.ion-ios-color-filter-outline:before,.ion-ios-color-filter:before,.ion-ios-color-wand-outline:before,.ion-ios-color-wand:before,.ion-ios-compose-outline:before,.ion-ios-compose:before,.ion-ios-contact-outline:before,.ion-ios-contact:before,.ion-ios-copy-outline:before,.ion-ios-copy:before,.ion-ios-crop-strong:before,.ion-ios-crop:before,.ion-ios-download-outline:before,.ion-ios-download:before,.ion-ios-drag:before,.ion-ios-email-outline:before,.ion-ios-email:before,.ion-ios-eye-outline:before,.ion-ios-eye:before,.ion-ios-fastforward-outline:before,.ion-ios-fastforward:before,.ion-ios-filing-outline:before,.ion-ios-filing:before,.ion-ios-film-outline:before,.ion-ios-film:before,.ion-ios-flag-outline:before,.ion-ios-flag:before,.ion-ios-flame-outline:before,.ion-ios-flame:before,.ion-ios-flask-outline:before,.ion-ios-flask:before,.ion-ios-flower-outline:before,.ion-ios-flower:before,.ion-ios-folder-outline:before,.ion-ios-folder:before,.ion-ios-football-outline:before,.ion-ios-football:before,.ion-ios-game-controller-a-outline:before,.ion-ios-game-controller-a:before,.ion-ios-game-controller-b-outline:before,.ion-ios-game-controller-b:before,.ion-ios-gear-outline:before,.ion-ios-gear:before,.ion-ios-glasses-outline:before,.ion-ios-glasses:before,.ion-ios-grid-view-outline:before,.ion-ios-grid-view:before,.ion-ios-heart-outline:before,.ion-ios-heart:before,.ion-ios-help-empty:before,.ion-ios-help-outline:before,.ion-ios-help:before,.ion-ios-home-outline:before,.ion-ios-home:before,.ion-ios-infinite-outline:before,.ion-ios-infinite:before,.ion-ios-information-empty:before,.ion-ios-information-outline:before,.ion-ios-information:before,.ion-ios-ionic-outline:before,.ion-ios-keypad-outline:before,.ion-ios-keypad:before,.ion-ios-lightbulb-outline:before,.ion-ios-lightbulb:before,.ion-ios-list-outline:before,.ion-ios-list:before,.ion-ios-location-outline:before,.ion-ios-location:before,.ion-ios-locked-outline:before,.ion-ios-locked:before,.ion-ios-loop-strong:before,.ion-ios-loop:before,.ion-ios-medical-outline:before,.ion-ios-medical:before,.ion-ios-medkit-outline:before,.ion-ios-medkit:before,.ion-ios-mic-off:before,.ion-ios-mic-outline:before,.ion-ios-mic:before,.ion-ios-minus-empty:before,.ion-ios-minus-outline:before,.ion-ios-minus:before,.ion-ios-monitor-outline:before,.ion-ios-monitor:before,.ion-ios-moon-outline:before,.ion-ios-moon:before,.ion-ios-more-outline:before,.ion-ios-more:before,.ion-ios-musical-note:before,.ion-ios-musical-notes:before,.ion-ios-navigate-outline:before,.ion-ios-navigate:before,.ion-ios-nutrition-outline:before,.ion-ios-nutrition:before,.ion-ios-paper-outline:before,.ion-ios-paper:before,.ion-ios-paperplane-outline:before,.ion-ios-paperplane:before,.ion-ios-partlysunny-outline:before,.ion-ios-partlysunny:before,.ion-ios-pause-outline:before,.ion-ios-pause:before,.ion-ios-paw-outline:before,.ion-ios-paw:before,.ion-ios-people-outline:before,.ion-ios-people:before,.ion-ios-person-outline:before,.ion-ios-person:before,.ion-ios-personadd-outline:before,.ion-ios-personadd:before,.ion-ios-photos-outline:before,.ion-ios-photos:before,.ion-ios-pie-outline:before,.ion-ios-pie:before,.ion-ios-pint-outline:before,.ion-ios-pint:before,.ion-ios-play-outline:before,.ion-ios-play:before,.ion-ios-plus-empty:before,.ion-ios-plus-outline:before,.ion-ios-plus:before,.ion-ios-pricetag-outline:before,.ion-ios-pricetag:before,.ion-ios-pricetags-outline:before,.ion-ios-pricetags:before,.ion-ios-printer-outline:before,.ion-ios-printer:before,.ion-ios-pulse-strong:before,.ion-ios-pulse:before,.ion-ios-rainy-outline:before,.ion-ios-rainy:before,.ion-ios-recording-outline:before,.ion-ios-recording:before,.ion-ios-redo-outline:before,.ion-ios-redo:before,.ion-ios-refresh-empty:before,.ion-ios-refresh-outline:before,.ion-ios-refresh:before,.ion-ios-reload:before,.ion-ios-reverse-camera-outline:before,.ion-ios-reverse-camera:before,.ion-ios-rewind-outline:before,.ion-ios-rewind:before,.ion-ios-rose-outline:before,.ion-ios-rose:before,.ion-ios-search-strong:before,.ion-ios-search:before,.ion-ios-settings-strong:before,.ion-ios-settings:before,.ion-ios-shuffle-strong:before,.ion-ios-shuffle:before,.ion-ios-skipbackward-outline:before,.ion-ios-skipbackward:before,.ion-ios-skipforward-outline:before,.ion-ios-skipforward:before,.ion-ios-snowy:before,.ion-ios-speedometer-outline:before,.ion-ios-speedometer:before,.ion-ios-star-half:before,.ion-ios-star-outline:before,.ion-ios-star:before,.ion-ios-stopwatch-outline:before,.ion-ios-stopwatch:before,.ion-ios-sunny-outline:before,.ion-ios-sunny:before,.ion-ios-telephone-outline:before,.ion-ios-telephone:before,.ion-ios-tennisball-outline:before,.ion-ios-tennisball:before,.ion-ios-thunderstorm-outline:before,.ion-ios-thunderstorm:before,.ion-ios-time-outline:before,.ion-ios-time:before,.ion-ios-timer-outline:before,.ion-ios-timer:before,.ion-ios-toggle-outline:before,.ion-ios-toggle:before,.ion-ios-trash-outline:before,.ion-ios-trash:before,.ion-ios-undo-outline:before,.ion-ios-undo:before,.ion-ios-unlocked-outline:before,.ion-ios-unlocked:before,.ion-ios-upload-outline:before,.ion-ios-upload:before,.ion-ios-videocam-outline:before,.ion-ios-videocam:before,.ion-ios-volume-high:before,.ion-ios-volume-low:before,.ion-ios-wineglass-outline:before,.ion-ios-wineglass:before,.ion-ios-world-outline:before,.ion-ios-world:before,.ion-ipad:before,.ion-iphone:before,.ion-ipod:before,.ion-jet:before,.ion-key:before,.ion-knife:before,.ion-laptop:before,.ion-leaf:before,.ion-levels:before,.ion-lightbulb:before,.ion-link:before,.ion-load-a:before,.ion-load-b:before,.ion-load-c:before,.ion-load-d:before,.ion-location:before,.ion-lock-combination:before,.ion-locked:before,.ion-log-in:before,.ion-log-out:before,.ion-loop:before,.ion-magnet:before,.ion-male:before,.ion-man:before,.ion-map:before,.ion-medkit:before,.ion-merge:before,.ion-mic-a:before,.ion-mic-b:before,.ion-mic-c:before,.ion-minus-circled:before,.ion-minus-round:before,.ion-minus:before,.ion-model-s:before,.ion-monitor:before,.ion-more:before,.ion-mouse:before,.ion-music-note:before,.ion-navicon-round:before,.ion-navicon:before,.ion-navigate:before,.ion-network:before,.ion-no-smoking:before,.ion-nuclear:before,.ion-outlet:before,.ion-paintbrush:before,.ion-paintbucket:before,.ion-paper-airplane:before,.ion-paperclip:before,.ion-pause:before,.ion-person-add:before,.ion-person-stalker:before,.ion-person:before,.ion-pie-graph:before,.ion-pin:before,.ion-pinpoint:before,.ion-pizza:before,.ion-plane:before,.ion-planet:before,.ion-play:before,.ion-playstation:before,.ion-plus-circled:before,.ion-plus-round:before,.ion-plus:before,.ion-podium:before,.ion-pound:before,.ion-power:before,.ion-pricetag:before,.ion-pricetags:before,.ion-printer:before,.ion-pull-request:before,.ion-qr-scanner:before,.ion-quote:before,.ion-radio-waves:before,.ion-record:before,.ion-refresh:before,.ion-reply-all:before,.ion-reply:before,.ion-ribbon-a:before,.ion-ribbon-b:before,.ion-sad-outline:before,.ion-sad:before,.ion-scissors:before,.ion-search:before,.ion-settings:before,.ion-share:before,.ion-shuffle:before,.ion-skip-backward:before,.ion-skip-forward:before,.ion-social-android-outline:before,.ion-social-android:before,.ion-social-angular-outline:before,.ion-social-angular:before,.ion-social-apple-outline:before,.ion-social-apple:before,.ion-social-bitcoin-outline:before,.ion-social-bitcoin:before,.ion-social-buffer-outline:before,.ion-social-buffer:before,.ion-social-chrome-outline:before,.ion-social-chrome:before,.ion-social-codepen-outline:before,.ion-social-codepen:before,.ion-social-css3-outline:before,.ion-social-css3:before,.ion-social-designernews-outline:before,.ion-social-designernews:before,.ion-social-dribbble-outline:before,.ion-social-dribbble:before,.ion-social-dropbox-outline:before,.ion-social-dropbox:before,.ion-social-euro-outline:before,.ion-social-euro:before,.ion-social-facebook-outline:before,.ion-social-facebook:before,.ion-social-foursquare-outline:before,.ion-social-foursquare:before,.ion-social-freebsd-devil:before,.ion-social-github-outline:before,.ion-social-github:before,.ion-social-google-outline:before,.ion-social-google:before,.ion-social-googleplus-outline:before,.ion-social-googleplus:before,.ion-social-hackernews-outline:before,.ion-social-hackernews:before,.ion-social-html5-outline:before,.ion-social-html5:before,.ion-social-instagram-outline:before,.ion-social-instagram:before,.ion-social-javascript-outline:before,.ion-social-javascript:before,.ion-social-linkedin-outline:before,.ion-social-linkedin:before,.ion-social-markdown:before,.ion-social-nodejs:before,.ion-social-octocat:before,.ion-social-pinterest-outline:before,.ion-social-pinterest:before,.ion-social-python:before,.ion-social-reddit-outline:before,.ion-social-reddit:before,.ion-social-rss-outline:before,.ion-social-rss:before,.ion-social-sass:before,.ion-social-skype-outline:before,.ion-social-skype:before,.ion-social-snapchat-outline:before,.ion-social-snapchat:before,.ion-social-tumblr-outline:before,.ion-social-tumblr:before,.ion-social-tux:before,.ion-social-twitch-outline:before,.ion-social-twitch:before,.ion-social-twitter-outline:before,.ion-social-twitter:before,.ion-social-usd-outline:before,.ion-social-usd:before,.ion-social-vimeo-outline:before,.ion-social-vimeo:before,.ion-social-whatsapp-outline:before,.ion-social-whatsapp:before,.ion-social-windows-outline:before,.ion-social-windows:before,.ion-social-wordpress-outline:before,.ion-social-wordpress:before,.ion-social-yahoo-outline:before,.ion-social-yahoo:before,.ion-social-yen-outline:before,.ion-social-yen:before,.ion-social-youtube-outline:before,.ion-social-youtube:before,.ion-soup-can-outline:before,.ion-soup-can:before,.ion-speakerphone:before,.ion-speedometer:before,.ion-spoon:before,.ion-star:before,.ion-stats-bars:before,.ion-steam:before,.ion-stop:before,.ion-thermometer:before,.ion-thumbsdown:before,.ion-thumbsup:before,.ion-toggle-filled:before,.ion-toggle:before,.ion-transgender:before,.ion-trash-a:before,.ion-trash-b:before,.ion-trophy:before,.ion-tshirt-outline:before,.ion-tshirt:before,.ion-umbrella:before,.ion-university:before,.ion-unlocked:before,.ion-upload:before,.ion-usb:before,.ion-videocamera:before,.ion-volume-high:before,.ion-volume-low:before,.ion-volume-medium:before,.ion-volume-mute:before,.ion-wand:before,.ion-waterdrop:before,.ion-wifi:before,.ion-wineglass:before,.ion-woman:before,.ion-wrench:before,.ion-xbox:before,.ionicons{display:inline-block;font-family:Ionicons;speak:none;font-style:normal;font-weight:400;font-variant:normal;text-transform:none;text-rendering:auto;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.ion-alert:before{content:"ļ„"}.ion-alert-circled:before{content:"ļ„€"}.ion-android-add:before{content:"ļ‹‡"}.ion-android-add-circle:before{content:"ļ™"}.ion-android-alarm-clock:before{content:"ļš"}.ion-android-alert:before{content:"ļ›"}.ion-android-apps:before{content:"ļœ"}.ion-android-archive:before{content:"ļ‹‰"}.ion-android-arrow-back:before{content:"ļ‹Š"}.ion-android-arrow-down:before{content:"ļ"}.ion-android-arrow-dropdown:before{content:"ļŸ"}.ion-android-arrow-dropdown-circle:before{content:"ļž"}.ion-android-arrow-dropleft:before{content:"ļ”"}.ion-android-arrow-dropleft-circle:before{content:"ļ "}.ion-android-arrow-dropright:before{content:"ļ£"}.ion-android-arrow-dropright-circle:before{content:"ļ¢"}.ion-android-arrow-dropup:before{content:"ļ„"}.ion-android-arrow-dropup-circle:before{content:"ļ¤"}.ion-android-arrow-forward:before{content:"ļŒ"}.ion-android-arrow-up:before{content:"ļ¦"}.ion-android-attach:before{content:"ļ§"}.ion-android-bar:before{content:"ļØ"}.ion-android-bicycle:before{content:"ļ©"}.ion-android-boat:before{content:"ļŖ"}.ion-android-bookmark:before{content:"ļ«"}.ion-android-bulb:before{content:"ļ¬"}.ion-android-bus:before{content:"ļ­"}.ion-android-calendar:before{content:"ļ‹‘"}.ion-android-call:before{content:"ļ‹’"}.ion-android-camera:before{content:"ļ‹“"}.ion-android-cancel:before{content:"ļ®"}.ion-android-car:before{content:"ļÆ"}.ion-android-cart:before{content:"ļ°"}.ion-android-chat:before{content:"ļ‹”"}.ion-android-checkbox:before{content:"ļ“"}.ion-android-checkbox-blank:before{content:"ļ±"}.ion-android-checkbox-outline:before{content:"ļ³"}.ion-android-checkbox-outline-blank:before{content:"ļ²"}.ion-android-checkmark-circle:before{content:"ļµ"}.ion-android-clipboard:before{content:"ļ¶"}.ion-android-close:before{content:"ļ‹—"}.ion-android-cloud:before{content:"ļŗ"}.ion-android-cloud-circle:before{content:"ļ·"}.ion-android-cloud-done:before{content:"ļø"}.ion-android-cloud-outline:before{content:"ļ¹"}.ion-android-color-palette:before{content:"ļ»"}.ion-android-compass:before{content:"ļ¼"}.ion-android-contact:before{content:"ļ‹˜"}.ion-android-contacts:before{content:"ļ‹™"}.ion-android-contract:before{content:"ļ½"}.ion-android-create:before{content:"ļ¾"}.ion-android-delete:before{content:"ļæ"}.ion-android-desktop:before{content:"ļŽ€"}.ion-android-document:before{content:"ļŽ"}.ion-android-done:before{content:"ļŽƒ"}.ion-android-done-all:before{content:"ļŽ‚"}.ion-android-download:before{content:"ļ‹"}.ion-android-drafts:before{content:"ļŽ„"}.ion-android-exit:before{content:"ļŽ…"}.ion-android-expand:before{content:"ļŽ†"}.ion-android-favorite:before{content:"ļŽˆ"}.ion-android-favorite-outline:before{content:"ļŽ‡"}.ion-android-film:before{content:"ļŽ‰"}.ion-android-folder:before{content:"ļ‹ "}.ion-android-folder-open:before{content:"ļŽŠ"}.ion-android-funnel:before{content:"ļŽ‹"}.ion-android-globe:before{content:"ļŽŒ"}.ion-android-hand:before{content:"ļ‹£"}.ion-android-hangout:before{content:"ļŽ"}.ion-android-happy:before{content:"ļŽŽ"}.ion-android-home:before{content:"ļŽ"}.ion-android-image:before{content:"ļ‹¤"}.ion-android-laptop:before{content:"ļŽ"}.ion-android-list:before{content:"ļŽ‘"}.ion-android-locate:before{content:"ļ‹©"}.ion-android-lock:before{content:"ļŽ’"}.ion-android-mail:before{content:"ļ‹«"}.ion-android-map:before{content:"ļŽ“"}.ion-android-menu:before{content:"ļŽ”"}.ion-android-microphone:before{content:"ļ‹¬"}.ion-android-microphone-off:before{content:"ļŽ•"}.ion-android-more-horizontal:before{content:"ļŽ–"}.ion-android-more-vertical:before{content:"ļŽ—"}.ion-android-navigate:before{content:"ļŽ˜"}.ion-android-notifications:before{content:"ļŽ›"}.ion-android-notifications-none:before{content:"ļŽ™"}.ion-android-notifications-off:before{content:"ļŽš"}.ion-android-open:before{content:"ļŽœ"}.ion-android-options:before{content:"ļŽ"}.ion-android-people:before{content:"ļŽž"}.ion-android-person:before{content:"ļŽ "}.ion-android-person-add:before{content:"ļŽŸ"}.ion-android-phone-landscape:before{content:"ļŽ”"}.ion-android-phone-portrait:before{content:"ļŽ¢"}.ion-android-pin:before{content:"ļŽ£"}.ion-android-plane:before{content:"ļŽ¤"}.ion-android-playstore:before{content:"ļ‹°"}.ion-android-print:before{content:"ļŽ„"}.ion-android-radio-button-off:before{content:"ļŽ¦"}.ion-android-radio-button-on:before{content:"ļŽ§"}.ion-android-refresh:before{content:"ļŽØ"}.ion-android-remove:before{content:"ļ‹“"}.ion-android-remove-circle:before{content:"ļŽ©"}.ion-android-restaurant:before{content:"ļŽŖ"}.ion-android-sad:before{content:"ļŽ«"}.ion-android-search:before{content:"ļ‹µ"}.ion-android-send:before{content:"ļ‹¶"}.ion-android-settings:before{content:"ļ‹·"}.ion-android-share:before{content:"ļ‹ø"}.ion-android-share-alt:before{content:"ļŽ¬"}.ion-android-star:before{content:"ļ‹¼"}.ion-android-star-half:before{content:"ļŽ­"}.ion-android-star-outline:before{content:"ļŽ®"}.ion-android-stopwatch:before{content:"ļ‹½"}.ion-android-subway:before{content:"ļŽÆ"}.ion-android-sunny:before{content:"ļŽ°"}.ion-android-sync:before{content:"ļŽ±"}.ion-android-textsms:before{content:"ļŽ²"}.ion-android-time:before{content:"ļŽ³"}.ion-android-train:before{content:"ļŽ“"}.ion-android-unlock:before{content:"ļŽµ"}.ion-android-upload:before{content:"ļŽ¶"}.ion-android-volume-down:before{content:"ļŽ·"}.ion-android-volume-mute:before{content:"ļŽø"}.ion-android-volume-off:before{content:"ļŽ¹"}.ion-android-volume-up:before{content:"ļŽŗ"}.ion-android-walk:before{content:"ļŽ»"}.ion-android-warning:before{content:"ļŽ¼"}.ion-android-watch:before{content:"ļŽ½"}.ion-android-wifi:before{content:"ļŒ…"}.ion-aperture:before{content:"ļŒ“"}.ion-archive:before{content:"ļ„‚"}.ion-arrow-down-a:before{content:"ļ„ƒ"}.ion-arrow-down-b:before{content:"ļ„„"}.ion-arrow-down-c:before{content:"ļ„…"}.ion-arrow-expand:before{content:"ļ‰ž"}.ion-arrow-graph-down-left:before{content:"ļ‰Ÿ"}.ion-arrow-graph-down-right:before{content:"ļ‰ "}.ion-arrow-graph-up-left:before{content:"ļ‰”"}.ion-arrow-graph-up-right:before{content:"ļ‰¢"}.ion-arrow-left-a:before{content:"ļ„†"}.ion-arrow-left-b:before{content:"ļ„‡"}.ion-arrow-left-c:before{content:"ļ„ˆ"}.ion-arrow-move:before{content:"ļ‰£"}.ion-arrow-resize:before{content:"ļ‰¤"}.ion-arrow-return-left:before{content:"ļ‰„"}.ion-arrow-return-right:before{content:"ļ‰¦"}.ion-arrow-right-a:before{content:"ļ„‰"}.ion-arrow-right-b:before{content:"ļ„Š"}.ion-arrow-right-c:before{content:"ļ„‹"}.ion-arrow-shrink:before{content:"ļ‰§"}.ion-arrow-swap:before{content:"ļ‰Ø"}.ion-arrow-up-a:before{content:"ļ„Œ"}.ion-arrow-up-b:before{content:"ļ„"}.ion-arrow-up-c:before{content:"ļ„Ž"}.ion-asterisk:before{content:"ļŒ”"}.ion-at:before{content:"ļ„"}.ion-backspace:before{content:"ļŽæ"}.ion-backspace-outline:before{content:"ļŽ¾"}.ion-bag:before{content:"ļ„"}.ion-battery-charging:before{content:"ļ„‘"}.ion-battery-empty:before{content:"ļ„’"}.ion-battery-full:before{content:"ļ„“"}.ion-battery-half:before{content:"ļ„”"}.ion-battery-low:before{content:"ļ„•"}.ion-beaker:before{content:"ļ‰©"}.ion-beer:before{content:"ļ‰Ŗ"}.ion-bluetooth:before{content:"ļ„–"}.ion-bonfire:before{content:"ļŒ•"}.ion-bookmark:before{content:"ļ‰«"}.ion-bowtie:before{content:"ļ€"}.ion-briefcase:before{content:"ļ‰¬"}.ion-bug:before{content:"ļŠ¾"}.ion-calculator:before{content:"ļ‰­"}.ion-calendar:before{content:"ļ„—"}.ion-camera:before{content:"ļ„˜"}.ion-card:before{content:"ļ„™"}.ion-cash:before{content:"ļŒ–"}.ion-chatbox:before{content:"ļ„›"}.ion-chatbox-working:before{content:"ļ„š"}.ion-chatboxes:before{content:"ļ„œ"}.ion-chatbubble:before{content:"ļ„ž"}.ion-chatbubble-working:before{content:"ļ„"}.ion-chatbubbles:before{content:"ļ„Ÿ"}.ion-checkmark:before{content:"ļ„¢"}.ion-checkmark-circled:before{content:"ļ„ "}.ion-checkmark-round:before{content:"ļ„”"}.ion-chevron-down:before{content:"ļ„£"}.ion-chevron-left:before{content:"ļ„¤"}.ion-chevron-right:before{content:"ļ„„"}.ion-chevron-up:before{content:"ļ„¦"}.ion-clipboard:before{content:"ļ„§"}.ion-clock:before{content:"ļ‰®"}.ion-close:before{content:"ļ„Ŗ"}.ion-close-circled:before{content:"ļ„Ø"}.ion-close-round:before{content:"ļ„©"}.ion-closed-captioning:before{content:"ļŒ—"}.ion-cloud:before{content:"ļ„«"}.ion-code:before{content:"ļ‰±"}.ion-code-download:before{content:"ļ‰Æ"}.ion-code-working:before{content:"ļ‰°"}.ion-coffee:before{content:"ļ‰²"}.ion-compass:before{content:"ļ‰³"}.ion-compose:before{content:"ļ„¬"}.ion-connection-bars:before{content:"ļ‰“"}.ion-contrast:before{content:"ļ‰µ"}.ion-crop:before{content:"ļ"}.ion-cube:before{content:"ļŒ˜"}.ion-disc:before{content:"ļ„­"}.ion-document:before{content:"ļ„Æ"}.ion-document-text:before{content:"ļ„®"}.ion-drag:before{content:"ļ„°"}.ion-earth:before{content:"ļ‰¶"}.ion-easel:before{content:"ļ‚"}.ion-edit:before{content:"ļŠæ"}.ion-egg:before{content:"ļ‰·"}.ion-eject:before{content:"ļ„±"}.ion-email:before{content:"ļ„²"}.ion-email-unread:before{content:"ļƒ"}.ion-erlenmeyer-flask:before{content:"ļ…"}.ion-erlenmeyer-flask-bubbles:before{content:"ļ„"}.ion-eye:before{content:"ļ„³"}.ion-eye-disabled:before{content:"ļŒ†"}.ion-female:before{content:"ļ‰ø"}.ion-filing:before{content:"ļ„“"}.ion-film-marker:before{content:"ļ„µ"}.ion-fireball:before{content:"ļŒ™"}.ion-flag:before{content:"ļ‰¹"}.ion-flame:before{content:"ļŒš"}.ion-flash:before{content:"ļ„·"}.ion-flash-off:before{content:"ļ„¶"}.ion-folder:before{content:"ļ„¹"}.ion-fork:before{content:"ļ‰ŗ"}.ion-fork-repo:before{content:"ļ‹€"}.ion-forward:before{content:"ļ„ŗ"}.ion-funnel:before{content:"ļŒ›"}.ion-gear-a:before{content:"ļ„½"}.ion-gear-b:before{content:"ļ„¾"}.ion-grid:before{content:"ļ„æ"}.ion-hammer:before{content:"ļ‰»"}.ion-happy:before{content:"ļŒœ"}.ion-happy-outline:before{content:"ļ†"}.ion-headphone:before{content:"ļ…€"}.ion-heart:before{content:"ļ…"}.ion-heart-broken:before{content:"ļŒ"}.ion-help:before{content:"ļ…ƒ"}.ion-help-buoy:before{content:"ļ‰¼"}.ion-help-circled:before{content:"ļ…‚"}.ion-home:before{content:"ļ…„"}.ion-icecream:before{content:"ļ‰½"}.ion-image:before{content:"ļ…‡"}.ion-images:before{content:"ļ…ˆ"}.ion-information:before{content:"ļ…Š"}.ion-information-circled:before{content:"ļ…‰"}.ion-ionic:before{content:"ļ…‹"}.ion-ios-alarm:before{content:"ļˆ"}.ion-ios-alarm-outline:before{content:"ļ‡"}.ion-ios-albums:before{content:"ļŠ"}.ion-ios-albums-outline:before{content:"ļ‰"}.ion-ios-americanfootball:before{content:"ļŒ"}.ion-ios-americanfootball-outline:before{content:"ļ‹"}.ion-ios-analytics:before{content:"ļŽ"}.ion-ios-analytics-outline:before{content:"ļ"}.ion-ios-arrow-back:before{content:"ļ"}.ion-ios-arrow-down:before{content:"ļ"}.ion-ios-arrow-forward:before{content:"ļ‘"}.ion-ios-arrow-left:before{content:"ļ’"}.ion-ios-arrow-right:before{content:"ļ“"}.ion-ios-arrow-thin-down:before{content:"ļ”"}.ion-ios-arrow-thin-left:before{content:"ļ•"}.ion-ios-arrow-thin-right:before{content:"ļ–"}.ion-ios-arrow-thin-up:before{content:"ļ—"}.ion-ios-arrow-up:before{content:"ļ˜"}.ion-ios-at:before{content:"ļš"}.ion-ios-at-outline:before{content:"ļ™"}.ion-ios-barcode:before{content:"ļœ"}.ion-ios-barcode-outline:before{content:"ļ›"}.ion-ios-baseball:before{content:"ļž"}.ion-ios-baseball-outline:before{content:"ļ"}.ion-ios-basketball:before{content:"ļ "}.ion-ios-basketball-outline:before{content:"ļŸ"}.ion-ios-bell:before{content:"ļ¢"}.ion-ios-bell-outline:before{content:"ļ”"}.ion-ios-body:before{content:"ļ¤"}.ion-ios-body-outline:before{content:"ļ£"}.ion-ios-bolt:before{content:"ļ¦"}.ion-ios-bolt-outline:before{content:"ļ„"}.ion-ios-book:before{content:"ļØ"}.ion-ios-book-outline:before{content:"ļ§"}.ion-ios-bookmarks:before{content:"ļŖ"}.ion-ios-bookmarks-outline:before{content:"ļ©"}.ion-ios-box:before{content:"ļ¬"}.ion-ios-box-outline:before{content:"ļ«"}.ion-ios-briefcase:before{content:"ļ®"}.ion-ios-briefcase-outline:before{content:"ļ­"}.ion-ios-browsers:before{content:"ļ°"}.ion-ios-browsers-outline:before{content:"ļÆ"}.ion-ios-calculator:before{content:"ļ²"}.ion-ios-calculator-outline:before{content:"ļ±"}.ion-ios-calendar:before{content:"ļ“"}.ion-ios-calendar-outline:before{content:"ļ³"}.ion-ios-camera:before{content:"ļ¶"}.ion-ios-camera-outline:before{content:"ļµ"}.ion-ios-cart:before{content:"ļø"}.ion-ios-cart-outline:before{content:"ļ·"}.ion-ios-chatboxes:before{content:"ļŗ"}.ion-ios-chatboxes-outline:before{content:"ļ¹"}.ion-ios-chatbubble:before{content:"ļ¼"}.ion-ios-chatbubble-outline:before{content:"ļ»"}.ion-ios-checkmark:before{content:"ļæ"}.ion-ios-checkmark-empty:before{content:"ļ½"}.ion-ios-checkmark-outline:before{content:"ļ¾"}.ion-ios-circle-filled:before{content:"ļ€"}.ion-ios-circle-outline:before{content:"ļ"}.ion-ios-clock:before{content:"ļƒ"}.ion-ios-clock-outline:before{content:"ļ‚"}.ion-ios-close:before{content:"ļ†"}.ion-ios-close-empty:before{content:"ļ„"}.ion-ios-close-outline:before{content:"ļ…"}.ion-ios-cloud:before{content:"ļŒ"}.ion-ios-cloud-download:before{content:"ļˆ"}.ion-ios-cloud-download-outline:before{content:"ļ‡"}.ion-ios-cloud-outline:before{content:"ļ‰"}.ion-ios-cloud-upload:before{content:"ļ‹"}.ion-ios-cloud-upload-outline:before{content:"ļŠ"}.ion-ios-cloudy:before{content:"ļ"}.ion-ios-cloudy-night:before{content:"ļŽ"}.ion-ios-cloudy-night-outline:before{content:"ļ"}.ion-ios-cloudy-outline:before{content:"ļ"}.ion-ios-cog:before{content:"ļ’"}.ion-ios-cog-outline:before{content:"ļ‘"}.ion-ios-color-filter:before{content:"ļ”"}.ion-ios-color-filter-outline:before{content:"ļ“"}.ion-ios-color-wand:before{content:"ļ–"}.ion-ios-color-wand-outline:before{content:"ļ•"}.ion-ios-compose:before{content:"ļ˜"}.ion-ios-compose-outline:before{content:"ļ—"}.ion-ios-contact:before{content:"ļš"}.ion-ios-contact-outline:before{content:"ļ™"}.ion-ios-copy:before{content:"ļœ"}.ion-ios-copy-outline:before{content:"ļ›"}.ion-ios-crop:before{content:"ļž"}.ion-ios-crop-strong:before{content:"ļ"}.ion-ios-download:before{content:"ļ "}.ion-ios-download-outline:before{content:"ļŸ"}.ion-ios-drag:before{content:"ļ”"}.ion-ios-email:before{content:"ļ£"}.ion-ios-email-outline:before{content:"ļ¢"}.ion-ios-eye:before{content:"ļ„"}.ion-ios-eye-outline:before{content:"ļ¤"}.ion-ios-fastforward:before{content:"ļ§"}.ion-ios-fastforward-outline:before{content:"ļ¦"}.ion-ios-filing:before{content:"ļ©"}.ion-ios-filing-outline:before{content:"ļØ"}.ion-ios-film:before{content:"ļ«"}.ion-ios-film-outline:before{content:"ļŖ"}.ion-ios-flag:before{content:"ļ­"}.ion-ios-flag-outline:before{content:"ļ¬"}.ion-ios-flame:before{content:"ļÆ"}.ion-ios-flame-outline:before{content:"ļ®"}.ion-ios-flask:before{content:"ļ±"}.ion-ios-flask-outline:before{content:"ļ°"}.ion-ios-flower:before{content:"ļ³"}.ion-ios-flower-outline:before{content:"ļ²"}.ion-ios-folder:before{content:"ļµ"}.ion-ios-folder-outline:before{content:"ļ“"}.ion-ios-football:before{content:"ļ·"}.ion-ios-football-outline:before{content:"ļ¶"}.ion-ios-game-controller-a:before{content:"ļ¹"}.ion-ios-game-controller-a-outline:before{content:"ļø"}.ion-ios-game-controller-b:before{content:"ļ»"}.ion-ios-game-controller-b-outline:before{content:"ļŗ"}.ion-ios-gear:before{content:"ļ½"}.ion-ios-gear-outline:before{content:"ļ¼"}.ion-ios-glasses:before{content:"ļæ"}.ion-ios-glasses-outline:before{content:"ļ¾"}.ion-ios-grid-view:before{content:"ļ‘"}.ion-ios-grid-view-outline:before{content:"ļ‘€"}.ion-ios-heart:before{content:"ļ‘ƒ"}.ion-ios-heart-outline:before{content:"ļ‘‚"}.ion-ios-help:before{content:"ļ‘†"}.ion-ios-help-empty:before{content:"ļ‘„"}.ion-ios-help-outline:before{content:"ļ‘…"}.ion-ios-home:before{content:"ļ‘ˆ"}.ion-ios-home-outline:before{content:"ļ‘‡"}.ion-ios-infinite:before{content:"ļ‘Š"}.ion-ios-infinite-outline:before{content:"ļ‘‰"}.ion-ios-information:before{content:"ļ‘"}.ion-ios-information-empty:before{content:"ļ‘‹"}.ion-ios-information-outline:before{content:"ļ‘Œ"}.ion-ios-ionic-outline:before{content:"ļ‘Ž"}.ion-ios-keypad:before{content:"ļ‘"}.ion-ios-keypad-outline:before{content:"ļ‘"}.ion-ios-lightbulb:before{content:"ļ‘’"}.ion-ios-lightbulb-outline:before{content:"ļ‘‘"}.ion-ios-list:before{content:"ļ‘”"}.ion-ios-list-outline:before{content:"ļ‘“"}.ion-ios-location:before{content:"ļ‘–"}.ion-ios-location-outline:before{content:"ļ‘•"}.ion-ios-locked:before{content:"ļ‘˜"}.ion-ios-locked-outline:before{content:"ļ‘—"}.ion-ios-loop:before{content:"ļ‘š"}.ion-ios-loop-strong:before{content:"ļ‘™"}.ion-ios-medical:before{content:"ļ‘œ"}.ion-ios-medical-outline:before{content:"ļ‘›"}.ion-ios-medkit:before{content:"ļ‘ž"}.ion-ios-medkit-outline:before{content:"ļ‘"}.ion-ios-mic:before{content:"ļ‘”"}.ion-ios-mic-off:before{content:"ļ‘Ÿ"}.ion-ios-mic-outline:before{content:"ļ‘ "}.ion-ios-minus:before{content:"ļ‘¤"}.ion-ios-minus-empty:before{content:"ļ‘¢"}.ion-ios-minus-outline:before{content:"ļ‘£"}.ion-ios-monitor:before{content:"ļ‘¦"}.ion-ios-monitor-outline:before{content:"ļ‘„"}.ion-ios-moon:before{content:"ļ‘Ø"}.ion-ios-moon-outline:before{content:"ļ‘§"}.ion-ios-more:before{content:"ļ‘Ŗ"}.ion-ios-more-outline:before{content:"ļ‘©"}.ion-ios-musical-note:before{content:"ļ‘«"}.ion-ios-musical-notes:before{content:"ļ‘¬"}.ion-ios-navigate:before{content:"ļ‘®"}.ion-ios-navigate-outline:before{content:"ļ‘­"}.ion-ios-nutrition:before{content:"ļ‘°"}.ion-ios-nutrition-outline:before{content:"ļ‘Æ"}.ion-ios-paper:before{content:"ļ‘²"}.ion-ios-paper-outline:before{content:"ļ‘±"}.ion-ios-paperplane:before{content:"ļ‘“"}.ion-ios-paperplane-outline:before{content:"ļ‘³"}.ion-ios-partlysunny:before{content:"ļ‘¶"}.ion-ios-partlysunny-outline:before{content:"ļ‘µ"}.ion-ios-pause:before{content:"ļ‘ø"}.ion-ios-pause-outline:before{content:"ļ‘·"}.ion-ios-paw:before{content:"ļ‘ŗ"}.ion-ios-paw-outline:before{content:"ļ‘¹"}.ion-ios-people:before{content:"ļ‘¼"}.ion-ios-people-outline:before{content:"ļ‘»"}.ion-ios-person:before{content:"ļ‘¾"}.ion-ios-person-outline:before{content:"ļ‘½"}.ion-ios-personadd:before{content:"ļ’€"}.ion-ios-personadd-outline:before{content:"ļ‘æ"}.ion-ios-photos:before{content:"ļ’‚"}.ion-ios-photos-outline:before{content:"ļ’"}.ion-ios-pie:before{content:"ļ’„"}.ion-ios-pie-outline:before{content:"ļ’ƒ"}.ion-ios-pint:before{content:"ļ’†"}.ion-ios-pint-outline:before{content:"ļ’…"}.ion-ios-play:before{content:"ļ’ˆ"}.ion-ios-play-outline:before{content:"ļ’‡"}.ion-ios-plus:before{content:"ļ’‹"}.ion-ios-plus-empty:before{content:"ļ’‰"}.ion-ios-plus-outline:before{content:"ļ’Š"}.ion-ios-pricetag:before{content:"ļ’"}.ion-ios-pricetag-outline:before{content:"ļ’Œ"}.ion-ios-pricetags:before{content:"ļ’"}.ion-ios-pricetags-outline:before{content:"ļ’Ž"}.ion-ios-printer:before{content:"ļ’‘"}.ion-ios-printer-outline:before{content:"ļ’"}.ion-ios-pulse:before{content:"ļ’“"}.ion-ios-pulse-strong:before{content:"ļ’’"}.ion-ios-rainy:before{content:"ļ’•"}.ion-ios-rainy-outline:before{content:"ļ’”"}.ion-ios-recording:before{content:"ļ’—"}.ion-ios-recording-outline:before{content:"ļ’–"}.ion-ios-redo:before{content:"ļ’™"}.ion-ios-redo-outline:before{content:"ļ’˜"}.ion-ios-refresh:before{content:"ļ’œ"}.ion-ios-refresh-empty:before{content:"ļ’š"}.ion-ios-refresh-outline:before{content:"ļ’›"}.ion-ios-reload:before{content:"ļ’"}.ion-ios-reverse-camera:before{content:"ļ’Ÿ"}.ion-ios-reverse-camera-outline:before{content:"ļ’ž"}.ion-ios-rewind:before{content:"ļ’”"}.ion-ios-rewind-outline:before{content:"ļ’ "}.ion-ios-rose:before{content:"ļ’£"}.ion-ios-rose-outline:before{content:"ļ’¢"}.ion-ios-search:before{content:"ļ’„"}.ion-ios-search-strong:before{content:"ļ’¤"}.ion-ios-settings:before{content:"ļ’§"}.ion-ios-settings-strong:before{content:"ļ’¦"}.ion-ios-shuffle:before{content:"ļ’©"}.ion-ios-shuffle-strong:before{content:"ļ’Ø"}.ion-ios-skipbackward:before{content:"ļ’«"}.ion-ios-skipbackward-outline:before{content:"ļ’Ŗ"}.ion-ios-skipforward:before{content:"ļ’­"}.ion-ios-skipforward-outline:before{content:"ļ’¬"}.ion-ios-snowy:before{content:"ļ’®"}.ion-ios-speedometer:before{content:"ļ’°"}.ion-ios-speedometer-outline:before{content:"ļ’Æ"}.ion-ios-star:before{content:"ļ’³"}.ion-ios-star-half:before{content:"ļ’±"}.ion-ios-star-outline:before{content:"ļ’²"}.ion-ios-stopwatch:before{content:"ļ’µ"}.ion-ios-stopwatch-outline:before{content:"ļ’“"}.ion-ios-sunny:before{content:"ļ’·"}.ion-ios-sunny-outline:before{content:"ļ’¶"}.ion-ios-telephone:before{content:"ļ’¹"}.ion-ios-telephone-outline:before{content:"ļ’ø"}.ion-ios-tennisball:before{content:"ļ’»"}.ion-ios-tennisball-outline:before{content:"ļ’ŗ"}.ion-ios-thunderstorm:before{content:"ļ’½"}.ion-ios-thunderstorm-outline:before{content:"ļ’¼"}.ion-ios-time:before{content:"ļ’æ"}.ion-ios-time-outline:before{content:"ļ’¾"}.ion-ios-timer:before{content:"ļ“"}.ion-ios-timer-outline:before{content:"ļ“€"}.ion-ios-toggle:before{content:"ļ“ƒ"}.ion-ios-toggle-outline:before{content:"ļ“‚"}.ion-ios-trash:before{content:"ļ“…"}.ion-ios-trash-outline:before{content:"ļ“„"}.ion-ios-undo:before{content:"ļ“‡"}.ion-ios-undo-outline:before{content:"ļ“†"}.ion-ios-unlocked:before{content:"ļ“‰"}.ion-ios-unlocked-outline:before{content:"ļ“ˆ"}.ion-ios-upload:before{content:"ļ“‹"}.ion-ios-upload-outline:before{content:"ļ“Š"}.ion-ios-videocam:before{content:"ļ“"}.ion-ios-videocam-outline:before{content:"ļ“Œ"}.ion-ios-volume-high:before{content:"ļ“Ž"}.ion-ios-volume-low:before{content:"ļ“"}.ion-ios-wineglass:before{content:"ļ“‘"}.ion-ios-wineglass-outline:before{content:"ļ“"}.ion-ios-world:before{content:"ļ““"}.ion-ios-world-outline:before{content:"ļ“’"}.ion-ipad:before{content:"ļ‡¹"}.ion-iphone:before{content:"ļ‡ŗ"}.ion-ipod:before{content:"ļ‡»"}.ion-jet:before{content:"ļŠ•"}.ion-key:before{content:"ļŠ–"}.ion-knife:before{content:"ļŠ—"}.ion-laptop:before{content:"ļ‡¼"}.ion-leaf:before{content:"ļ‡½"}.ion-levels:before{content:"ļŠ˜"}.ion-lightbulb:before{content:"ļŠ™"}.ion-link:before{content:"ļ‡¾"}.ion-load-a:before{content:"ļŠš"}.ion-load-b:before{content:"ļŠ›"}.ion-load-c:before{content:"ļŠœ"}.ion-load-d:before{content:"ļŠ"}.ion-location:before{content:"ļ‡æ"}.ion-lock-combination:before{content:"ļ“”"}.ion-locked:before{content:"ļˆ€"}.ion-log-in:before{content:"ļŠž"}.ion-log-out:before{content:"ļŠŸ"}.ion-loop:before{content:"ļˆ"}.ion-magnet:before{content:"ļŠ "}.ion-male:before{content:"ļŠ”"}.ion-man:before{content:"ļˆ‚"}.ion-map:before{content:"ļˆƒ"}.ion-medkit:before{content:"ļŠ¢"}.ion-merge:before{content:"ļŒæ"}.ion-mic-a:before{content:"ļˆ„"}.ion-mic-b:before{content:"ļˆ…"}.ion-mic-c:before{content:"ļˆ†"}.ion-minus:before{content:"ļˆ‰"}.ion-minus-circled:before{content:"ļˆ‡"}.ion-minus-round:before{content:"ļˆˆ"}.ion-model-s:before{content:"ļ‹"}.ion-monitor:before{content:"ļˆŠ"}.ion-more:before{content:"ļˆ‹"}.ion-mouse:before{content:"ļ€"}.ion-music-note:before{content:"ļˆŒ"}.ion-navicon:before{content:"ļˆŽ"}.ion-navicon-round:before{content:"ļˆ"}.ion-navigate:before{content:"ļŠ£"}.ion-network:before{content:"ļ"}.ion-no-smoking:before{content:"ļ‹‚"}.ion-nuclear:before{content:"ļŠ¤"}.ion-outlet:before{content:"ļ‚"}.ion-paintbrush:before{content:"ļ“•"}.ion-paintbucket:before{content:"ļ“–"}.ion-paper-airplane:before{content:"ļ‹ƒ"}.ion-paperclip:before{content:"ļˆ"}.ion-pause:before{content:"ļˆ"}.ion-person:before{content:"ļˆ“"}.ion-person-add:before{content:"ļˆ‘"}.ion-person-stalker:before{content:"ļˆ’"}.ion-pie-graph:before{content:"ļŠ„"}.ion-pin:before{content:"ļŠ¦"}.ion-pinpoint:before{content:"ļŠ§"}.ion-pizza:before{content:"ļŠØ"}.ion-plane:before{content:"ļˆ”"}.ion-planet:before{content:"ļƒ"}.ion-play:before{content:"ļˆ•"}.ion-playstation:before{content:"ļŒŠ"}.ion-plus:before{content:"ļˆ˜"}.ion-plus-circled:before{content:"ļˆ–"}.ion-plus-round:before{content:"ļˆ—"}.ion-podium:before{content:"ļ„"}.ion-pound:before{content:"ļˆ™"}.ion-power:before{content:"ļŠ©"}.ion-pricetag:before{content:"ļŠŖ"}.ion-pricetags:before{content:"ļŠ«"}.ion-printer:before{content:"ļˆš"}.ion-pull-request:before{content:"ļ…"}.ion-qr-scanner:before{content:"ļ†"}.ion-quote:before{content:"ļ‡"}.ion-radio-waves:before{content:"ļŠ¬"}.ion-record:before{content:"ļˆ›"}.ion-refresh:before{content:"ļˆœ"}.ion-reply:before{content:"ļˆž"}.ion-reply-all:before{content:"ļˆ"}.ion-ribbon-a:before{content:"ļˆ"}.ion-ribbon-b:before{content:"ļ‰"}.ion-sad:before{content:"ļŠ"}.ion-sad-outline:before{content:"ļ“—"}.ion-scissors:before{content:"ļ‹"}.ion-search:before{content:"ļˆŸ"}.ion-settings:before{content:"ļŠ­"}.ion-share:before{content:"ļˆ "}.ion-shuffle:before{content:"ļˆ”"}.ion-skip-backward:before{content:"ļˆ¢"}.ion-skip-forward:before{content:"ļˆ£"}.ion-social-android:before{content:"ļˆ„"}.ion-social-android-outline:before{content:"ļˆ¤"}.ion-social-angular:before{content:"ļ“™"}.ion-social-angular-outline:before{content:"ļ“˜"}.ion-social-apple:before{content:"ļˆ§"}.ion-social-apple-outline:before{content:"ļˆ¦"}.ion-social-bitcoin:before{content:"ļŠÆ"}.ion-social-bitcoin-outline:before{content:"ļŠ®"}.ion-social-buffer:before{content:"ļˆ©"}.ion-social-buffer-outline:before{content:"ļˆØ"}.ion-social-chrome:before{content:"ļ“›"}.ion-social-chrome-outline:before{content:"ļ“š"}.ion-social-codepen:before{content:"ļ“"}.ion-social-codepen-outline:before{content:"ļ“œ"}.ion-social-css3:before{content:"ļ“Ÿ"}.ion-social-css3-outline:before{content:"ļ“ž"}.ion-social-designernews:before{content:"ļˆ«"}.ion-social-designernews-outline:before{content:"ļˆŖ"}.ion-social-dribbble:before{content:"ļˆ­"}.ion-social-dribbble-outline:before{content:"ļˆ¬"}.ion-social-dropbox:before{content:"ļˆÆ"}.ion-social-dropbox-outline:before{content:"ļˆ®"}.ion-social-euro:before{content:"ļ“”"}.ion-social-euro-outline:before{content:"ļ“ "}.ion-social-facebook:before{content:"ļˆ±"}.ion-social-facebook-outline:before{content:"ļˆ°"}.ion-social-foursquare:before{content:"ļ"}.ion-social-foursquare-outline:before{content:"ļŒ"}.ion-social-freebsd-devil:before{content:"ļ‹„"}.ion-social-github:before{content:"ļˆ³"}.ion-social-github-outline:before{content:"ļˆ²"}.ion-social-google:before{content:"ļ"}.ion-social-google-outline:before{content:"ļŽ"}.ion-social-googleplus:before{content:"ļˆµ"}.ion-social-googleplus-outline:before{content:"ļˆ“"}.ion-social-hackernews:before{content:"ļˆ·"}.ion-social-hackernews-outline:before{content:"ļˆ¶"}.ion-social-html5:before{content:"ļ“£"}.ion-social-html5-outline:before{content:"ļ“¢"}.ion-social-instagram:before{content:"ļ‘"}.ion-social-instagram-outline:before{content:"ļ"}.ion-social-javascript:before{content:"ļ“„"}.ion-social-javascript-outline:before{content:"ļ“¤"}.ion-social-linkedin:before{content:"ļˆ¹"}.ion-social-linkedin-outline:before{content:"ļˆø"}.ion-social-markdown:before{content:"ļ“¦"}.ion-social-nodejs:before{content:"ļ“§"}.ion-social-octocat:before{content:"ļ“Ø"}.ion-social-pinterest:before{content:"ļŠ±"}.ion-social-pinterest-outline:before{content:"ļŠ°"}.ion-social-python:before{content:"ļ“©"}.ion-social-reddit:before{content:"ļˆ»"}.ion-social-reddit-outline:before{content:"ļˆŗ"}.ion-social-rss:before{content:"ļˆ½"}.ion-social-rss-outline:before{content:"ļˆ¼"}.ion-social-sass:before{content:"ļ“Ŗ"}.ion-social-skype:before{content:"ļˆæ"}.ion-social-skype-outline:before{content:"ļˆ¾"}.ion-social-snapchat:before{content:"ļ“¬"}.ion-social-snapchat-outline:before{content:"ļ“«"}.ion-social-tumblr:before{content:"ļ‰"}.ion-social-tumblr-outline:before{content:"ļ‰€"}.ion-social-tux:before{content:"ļ‹…"}.ion-social-twitch:before{content:"ļ“®"}.ion-social-twitch-outline:before{content:"ļ“­"}.ion-social-twitter:before{content:"ļ‰ƒ"}.ion-social-twitter-outline:before{content:"ļ‰‚"}.ion-social-usd:before{content:"ļ“"}.ion-social-usd-outline:before{content:"ļ’"}.ion-social-vimeo:before{content:"ļ‰…"}.ion-social-vimeo-outline:before{content:"ļ‰„"}.ion-social-whatsapp:before{content:"ļ“°"}.ion-social-whatsapp-outline:before{content:"ļ“Æ"}.ion-social-windows:before{content:"ļ‰‡"}.ion-social-windows-outline:before{content:"ļ‰†"}.ion-social-wordpress:before{content:"ļ‰‰"}.ion-social-wordpress-outline:before{content:"ļ‰ˆ"}.ion-social-yahoo:before{content:"ļ‰‹"}.ion-social-yahoo-outline:before{content:"ļ‰Š"}.ion-social-yen:before{content:"ļ“²"}.ion-social-yen-outline:before{content:"ļ“±"}.ion-social-youtube:before{content:"ļ‰"}.ion-social-youtube-outline:before{content:"ļ‰Œ"}.ion-soup-can:before{content:"ļ““"}.ion-soup-can-outline:before{content:"ļ“³"}.ion-speakerphone:before{content:"ļŠ²"}.ion-speedometer:before{content:"ļŠ³"}.ion-spoon:before{content:"ļŠ“"}.ion-star:before{content:"ļ‰Ž"}.ion-stats-bars:before{content:"ļŠµ"}.ion-steam:before{content:"ļŒ‹"}.ion-stop:before{content:"ļ‰"}.ion-thermometer:before{content:"ļŠ¶"}.ion-thumbsdown:before{content:"ļ‰"}.ion-thumbsup:before{content:"ļ‰‘"}.ion-toggle:before{content:"ļ•"}.ion-toggle-filled:before{content:"ļ”"}.ion-transgender:before{content:"ļ“µ"}.ion-trash-a:before{content:"ļ‰’"}.ion-trash-b:before{content:"ļ‰“"}.ion-trophy:before{content:"ļ–"}.ion-tshirt:before{content:"ļ“·"}.ion-tshirt-outline:before{content:"ļ“¶"}.ion-umbrella:before{content:"ļŠ·"}.ion-university:before{content:"ļ—"}.ion-unlocked:before{content:"ļ‰”"}.ion-upload:before{content:"ļ‰•"}.ion-usb:before{content:"ļŠø"}.ion-videocamera:before{content:"ļ‰–"}.ion-volume-high:before{content:"ļ‰—"}.ion-volume-low:before{content:"ļ‰˜"}.ion-volume-medium:before{content:"ļ‰™"}.ion-volume-mute:before{content:"ļ‰š"}.ion-wand:before{content:"ļ˜"}.ion-waterdrop:before{content:"ļ‰›"}.ion-wifi:before{content:"ļ‰œ"}.ion-wineglass:before{content:"ļŠ¹"}.ion-woman:before{content:"ļ‰"}.ion-wrench:before{content:"ļŠŗ"}.ion-xbox:before{content:"ļŒŒ"}a,abbr,acronym,address,applet,article,aside,audio,b,big,blockquote,body,canvas,caption,center,cite,code,dd,del,details,dfn,div,dl,dt,em,embed,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,hgroup,html,i,iframe,img,ins,kbd,label,legend,li,mark,menu,nav,object,ol,output,p,pre,q,ruby,s,samp,section,small,span,strike,strong,sub,summary,sup,table,tbody,td,tfoot,th,thead,time,tr,tt,u,ul,var,video{margin:0;padding:0;border:0;vertical-align:baseline;font:inherit;font-size:100%}ol,ul{list-style:none}blockquote,q{quotes:none}audio:not([controls]){display:none;height:0}[hidden],template{display:none}script{display:none!important}html{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}:focus,a,a:active,a:focus,a:hover,button,button:focus{outline:0}a{-webkit-user-drag:none;-webkit-tap-highlight-color:transparent;-webkit-tap-highlight-color:transparent}a[href]:hover{cursor:pointer}b,strong{font-weight:700}dfn{font-style:italic}hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0}code,kbd,pre,samp{font-size:1em;font-family:monospace,serif}pre{white-space:pre-wrap}q{quotes:"\201C" "\201D" "\2018" "\2019"}sub,sup{position:relative;vertical-align:baseline;font-size:75%;line-height:0}sup{top:-.5em}sub{bottom:-.25em}fieldset{margin:0 2px;padding:.35em .625em .75em;border:1px solid silver}button,input,select,textarea{margin:0;outline-offset:0;outline-style:none;outline-width:0;-webkit-font-smoothing:inherit;background-image:none}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{cursor:pointer;-webkit-appearance:button}button[disabled],html input[disabled]{cursor:default}input[type=search]{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;-webkit-appearance:textfield}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0}textarea{overflow:auto}img{-webkit-user-drag:none}table{border-spacing:0;border-collapse:collapse}*,:after,:before{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{overflow:hidden;-ms-touch-action:pan-y;touch-action:pan-y}.ionic-body,body{-webkit-touch-callout:none;-webkit-font-smoothing:antialiased;font-smoothing:antialiased;-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none;-webkit-tap-highlight-color:transparent;-webkit-tap-highlight-color:transparent;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;top:0;right:0;bottom:0;left:0;overflow:hidden;margin:0;padding:0;color:#000;word-wrap:break-word;font-size:14px;font-family:-apple-system;font-family:"-apple-system","Helvetica Neue",Roboto,"Segoe UI",sans-serif;line-height:20px;text-rendering:optimizeLegibility;-webkit-backface-visibility:hidden;-webkit-user-drag:none;-ms-content-zooming:none}body.grade-b,body.grade-c{text-rendering:auto}.content{position:relative}.scroll-content{position:absolute;top:0;right:0;bottom:0;left:0;overflow:hidden;margin-top:-1px;padding-top:1px;margin-bottom:-1px;width:auto;height:auto}.menu .scroll-content.scroll-content-false{z-index:11}.scroll-view{position:relative;display:block;overflow:hidden;margin-top:-1px}.scroll-view.overflow-scroll{position:relative}.scroll-view.scroll-x{overflow-x:scroll;overflow-y:hidden}.scroll-view.scroll-y{overflow-x:hidden;overflow-y:scroll}.scroll-view.scroll-xy{overflow-x:scroll;overflow-y:scroll}.scroll{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-touch-callout:none;-webkit-text-size-adjust:none;-moz-text-size-adjust:none;text-size-adjust:none;-webkit-transform-origin:left top;transform-origin:left top}@-ms-viewport{width:device-width}.scroll-bar{position:absolute;z-index:9999}.ng-animate .scroll-bar{visibility:hidden}.scroll-bar-h{right:2px;bottom:3px;left:2px;height:3px}.scroll-bar-h .scroll-bar-indicator{height:100%}.scroll-bar-v{top:2px;right:3px;bottom:2px;width:3px}.scroll-bar-v .scroll-bar-indicator{width:100%}.scroll-bar-indicator{position:absolute;border-radius:4px;background:rgba(0,0,0,.3);opacity:1;-webkit-transition:opacity .3s linear;transition:opacity .3s linear}.scroll-bar-indicator.scroll-bar-fade-out{opacity:0}.platform-android .scroll-bar-indicator{border-radius:0}.grade-b .scroll-bar-indicator,.grade-c .scroll-bar-indicator{background:#aaa}.grade-b .scroll-bar-indicator.scroll-bar-fade-out,.grade-c .scroll-bar-indicator.scroll-bar-fade-out{-webkit-transition:none;transition:none}ion-infinite-scroll{height:60px;width:100%;display:block;display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-direction:normal;-webkit-box-orient:horizontal;-webkit-flex-direction:row;-moz-flex-direction:row;-ms-flex-direction:row;flex-direction:row;-webkit-box-pack:center;-ms-flex-pack:center;-webkit-justify-content:center;-moz-justify-content:center;justify-content:center;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center}ion-infinite-scroll .icon{font-size:30px;color:#666}ion-infinite-scroll:not(.active) .icon:before,ion-infinite-scroll:not(.active) .spinner{display:none}.overflow-scroll{overflow-x:hidden;overflow-y:scroll;-webkit-overflow-scrolling:touch;-ms-overflow-style:-ms-autohiding-scrollbar;top:0;right:0;bottom:0;left:0;position:absolute}.overflow-scroll.pane{overflow-x:hidden;overflow-y:scroll}.overflow-scroll .scroll{position:static;height:100%;-webkit-transform:translate3d(0,0,0)}.has-header{top:44px}.no-header{top:0}.has-subheader{top:88px}.has-tabs-top{top:93px}.has-header.has-subheader.has-tabs-top{top:137px}.has-footer{bottom:44px}.has-subfooter{bottom:88px}.bar-footer.has-tabs,.has-tabs{bottom:49px}.bar-footer.has-tabs.pane,.has-tabs.pane{bottom:49px;height:auto}.bar-subfooter.has-tabs,.has-footer.has-tabs{bottom:93px}.pane{-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);-webkit-transition-duration:0;transition-duration:0;z-index:1}.view{z-index:1}.pane,.view{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;background-color:#fff;overflow:hidden}.view-container{position:absolute;display:block;width:100%;height:100%}p{margin:0 0 10px}small{font-size:85%}cite{font-style:normal}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{color:#000;font-weight:500;font-family:"-apple-system","Helvetica Neue",Roboto,"Segoe UI",sans-serif;line-height:1.2}.h1 small,.h2 small,.h3 small,.h4 small,.h5 small,.h6 small,h1 small,h2 small,h3 small,h4 small,h5 small,h6 small{font-weight:400;line-height:1}.h1,.h2,.h3,h1,h2,h3{margin-top:20px;margin-bottom:10px}.h1:first-child,.h2:first-child,.h3:first-child,h1:first-child,h2:first-child,h3:first-child{margin-top:0}.h1+.h1,.h1+.h2,.h1+.h3,.h1+h1,.h1+h2,.h1+h3,.h2+.h1,.h2+.h2,.h2+.h3,.h2+h1,.h2+h2,.h2+h3,.h3+.h1,.h3+.h2,.h3+.h3,.h3+h1,.h3+h2,.h3+h3,h1+.h1,h1+.h2,h1+.h3,h1+h1,h1+h2,h1+h3,h2+.h1,h2+.h2,h2+.h3,h2+h1,h2+h2,h2+h3,h3+.h1,h3+.h2,h3+.h3,h3+h1,h3+h2,h3+h3{margin-top:10px}.h4,.h5,.h6,h4,h5,h6{margin-top:10px;margin-bottom:10px}.h1,h1{font-size:36px}.h2,h2{font-size:30px}.h3,h3{font-size:24px}.h4,h4{font-size:18px}.h5,h5{font-size:14px}.h6,h6{font-size:12px}.h1 small,h1 small{font-size:24px}.h2 small,h2 small{font-size:18px}.h3 small,.h4 small,h3 small,h4 small{font-size:14px}dl{margin-bottom:20px}dd,dt{line-height:1.42857}dt{font-weight:700}blockquote{margin:0 0 20px;padding:10px 20px;border-left:5px solid gray}blockquote p{font-weight:300;font-size:17.5px;line-height:1.25}blockquote p:last-child{margin-bottom:0}blockquote small{display:block;line-height:1.42857}blockquote small:before{content:'\2014 \00A0'}blockquote:after,blockquote:before,q:after,q:before{content:""}address{display:block;margin-bottom:20px;font-style:normal;line-height:1.42857}a{color:#387ef5}a.subdued{padding-right:10px;color:#888;text-decoration:none}a.subdued:hover{text-decoration:none}a.subdued:last-child{padding-right:0}.action-sheet-backdrop{-webkit-transition:background-color 150ms ease-in-out;transition:background-color 150ms ease-in-out;position:fixed;top:0;left:0;z-index:11;width:100%;height:100%;background-color:transparent}.action-sheet-backdrop.active{background-color:rgba(0,0,0,.4)}.action-sheet-wrapper{-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0);-webkit-transition:all cubic-bezier(.36,.66,.04,1) 500ms;transition:all cubic-bezier(.36,.66,.04,1) 500ms;position:absolute;bottom:0;left:0;right:0;width:100%;max-width:500px;margin:auto}.action-sheet-up{-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.action-sheet{margin-left:8px;margin-right:8px;width:auto;z-index:11;overflow:hidden}.action-sheet .button{display:block;padding:1px;width:100%;border-radius:0;border-color:#d1d3d6;background-color:transparent;color:#007aff;font-size:21px}.action-sheet .button:hover{color:#007aff}.action-sheet .button.destructive,.action-sheet .button.destructive:hover{color:#ff3b30}.action-sheet .button.activated,.action-sheet .button.active{box-shadow:none;border-color:#d1d3d6;color:#007aff;background:#e4e5e7}.action-sheet-has-icons .icon{position:absolute;left:16px}.action-sheet-title{padding:16px;color:#8f8f8f;text-align:center;font-size:13px}.action-sheet-group{margin-bottom:8px;border-radius:4px;background-color:#fff;overflow:hidden}.action-sheet-group .button{border-width:1px 0 0 0}.action-sheet-group .button:first-child:last-child{border-width:0}.action-sheet-options{background:#f1f2f3}.action-sheet-cancel .button{font-weight:500}.action-sheet-open,.action-sheet-open.modal-open .modal{pointer-events:none}.action-sheet-open .action-sheet-backdrop{pointer-events:auto}.platform-android .action-sheet-backdrop.active{background-color:rgba(0,0,0,.2)}.platform-android .action-sheet{margin:0}.platform-android .action-sheet .action-sheet-title,.platform-android .action-sheet .button{text-align:left;border-color:transparent;font-size:16px;color:inherit}.platform-android .action-sheet .action-sheet-title{font-size:14px;padding:16px;color:#666}.platform-android .action-sheet .button.activated,.platform-android .action-sheet .button.active{background:#e8e8e8}.platform-android .action-sheet-group{margin:0;border-radius:0;background-color:#fafafa}.platform-android .action-sheet-cancel{display:none}.platform-android .action-sheet-has-icons .button{padding-left:56px}.backdrop{position:fixed;top:0;left:0;z-index:11;width:100%;height:100%;background-color:rgba(0,0,0,.4);visibility:hidden;opacity:0;-webkit-transition:.1s opacity linear;transition:.1s opacity linear}.backdrop.visible{visibility:visible}.backdrop.active{opacity:1}.bar{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;position:absolute;right:0;left:0;z-index:9;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:5px;width:100%;height:44px;border-width:0;border-style:solid;border-top:1px solid transparent;border-bottom:1px solid #ddd;background-color:#fff;background-size:0}@media (min--moz-device-pixel-ratio:1.5),(-webkit-min-device-pixel-ratio:1.5),(min-device-pixel-ratio:1.5),(min-resolution:144dpi),(min-resolution:1.5dppx){.bar{border:none;background-image:linear-gradient(0deg,#ddd,#ddd 50%,transparent 50%);background-position:bottom;background-size:100% 1px;background-repeat:no-repeat}}.bar.bar-clear{border:none;background:0 0;color:#fff}.bar.bar-clear .button,.bar.bar-clear .title{color:#fff}.bar.item-input-inset .item-input-wrapper{margin-top:-1px}.bar.item-input-inset .item-input-wrapper input{padding-left:8px;width:94%;height:28px;background:0 0}.bar.bar-light{border-color:#ddd;background-color:#fff;background-image:linear-gradient(0deg,#ddd,#ddd 50%,transparent 50%);color:#444}.bar.bar-light .title{color:#444}.bar.bar-light.bar-footer{background-image:linear-gradient(180deg,#ddd,#ddd 50%,transparent 50%)}.bar.bar-stable{border-color:#b2b2b2;background-color:#f8f8f8;background-image:linear-gradient(0deg,#b2b2b2,#b2b2b2 50%,transparent 50%);color:#444}.bar.bar-stable .title{color:#444}.bar.bar-stable.bar-footer{background-image:linear-gradient(180deg,#b2b2b2,#b2b2b2 50%,transparent 50%)}.bar.bar-positive{border-color:#0c60ee;background-color:#387ef5;background-image:linear-gradient(0deg,#0c60ee,#0c60ee 50%,transparent 50%);color:#fff}.bar.bar-positive .title{color:#fff}.bar.bar-positive.bar-footer{background-image:linear-gradient(180deg,#0c60ee,#0c60ee 50%,transparent 50%)}.bar.bar-calm{border-color:#0a9dc7;background-color:#11c1f3;background-image:linear-gradient(0deg,#0a9dc7,#0a9dc7 50%,transparent 50%);color:#fff}.bar.bar-calm .title{color:#fff}.bar.bar-calm.bar-footer{background-image:linear-gradient(180deg,#0a9dc7,#0a9dc7 50%,transparent 50%)}.bar.bar-assertive{border-color:#e42112;background-color:#ef473a;background-image:linear-gradient(0deg,#e42112,#e42112 50%,transparent 50%);color:#fff}.bar.bar-assertive .title{color:#fff}.bar.bar-assertive.bar-footer{background-image:linear-gradient(180deg,#e42112,#e42112 50%,transparent 50%)}.bar.bar-balanced{border-color:#28a54c;background-color:#33cd5f;background-image:linear-gradient(0deg,#28a54c,#28a54c 50%,transparent 50%);color:#fff}.bar.bar-balanced .title{color:#fff}.bar.bar-balanced.bar-footer{background-image:linear-gradient(180deg,#28a54c,#28a54c 50%,transparent 50%)}.bar.bar-energized{border-color:#e6b500;background-color:#ffc900;background-image:linear-gradient(0deg,#e6b500,#e6b500 50%,transparent 50%);color:#fff}.bar.bar-energized .title{color:#fff}.bar.bar-energized.bar-footer{background-image:linear-gradient(180deg,#e6b500,#e6b500 50%,transparent 50%)}.bar.bar-royal{border-color:#6b46e5;background-color:#886aea;background-image:linear-gradient(0deg,#6b46e5,#6b46e5 50%,transparent 50%);color:#fff}.bar.bar-royal .title{color:#fff}.bar.bar-royal.bar-footer{background-image:linear-gradient(180deg,#6b46e5,#6b46e5 50%,transparent 50%)}.bar.bar-dark{border-color:#111;background-color:#444;background-image:linear-gradient(0deg,#111,#111 50%,transparent 50%);color:#fff}.bar.bar-dark .title{color:#fff}.bar.bar-dark.bar-footer{background-image:linear-gradient(180deg,#111,#111 50%,transparent 50%)}.bar .title{display:block;position:absolute;top:0;right:0;left:0;z-index:0;overflow:hidden;margin:0 10px;min-width:30px;height:43px;text-align:center;text-overflow:ellipsis;white-space:nowrap;font-size:17px;font-weight:500;line-height:44px}.bar .title.title-left{text-align:left}.bar .title.title-right{text-align:right}.bar .title a{color:inherit}.bar .button,.bar button{z-index:1;padding:0 8px;min-width:initial;min-height:31px;font-weight:400;font-size:13px;line-height:32px}.bar .button .icon:before,.bar .button.button-icon:before,.bar .button.icon-left:before,.bar .button.icon-right:before,.bar .button.icon:before,.bar button .icon:before,.bar button.button-icon:before,.bar button.icon-left:before,.bar button.icon-right:before,.bar button.icon:before{padding-right:2px;padding-left:2px;font-size:20px;line-height:32px}.bar .button.button-icon,.bar button.button-icon{font-size:17px}.bar .button.button-icon .icon:before,.bar .button.button-icon.icon-left:before,.bar .button.button-icon.icon-right:before,.bar .button.button-icon:before,.bar button.button-icon .icon:before,.bar button.button-icon.icon-left:before,.bar button.button-icon.icon-right:before,.bar button.button-icon:before{vertical-align:top;font-size:32px;line-height:32px}.bar .button.button-clear,.bar button.button-clear{padding-right:2px;padding-left:2px;font-weight:300;font-size:17px}.bar .button.button-clear .icon:before,.bar .button.button-clear.icon-left:before,.bar .button.button-clear.icon-right:before,.bar .button.button-clear.icon:before,.bar button.button-clear .icon:before,.bar button.button-clear.icon-left:before,.bar button.button-clear.icon-right:before,.bar button.button-clear.icon:before{font-size:32px;line-height:32px}.bar .button.back-button,.bar button.back-button{display:block;margin-right:5px;padding:0;white-space:nowrap;font-weight:400}.bar .button.back-button.activated,.bar .button.back-button.active,.bar button.back-button.activated,.bar button.back-button.active{opacity:.2}.bar .button-bar>.button,.bar .buttons>.button{min-height:31px;line-height:32px}.bar .button+.button-bar,.bar .button-bar+.button{margin-left:5px}.bar .buttons,.bar .buttons.primary-buttons,.bar .buttons.secondary-buttons{display:inherit}.bar .buttons span{display:inline-block}.bar .buttons-left span{margin-right:5px;display:inherit}.bar .buttons-right span{margin-left:5px;display:inherit}.bar .buttons.pull-right,.bar .title+.button:last-child,.bar .title+.buttons,.bar>.button+.button:last-child,.bar>.button.pull-right{position:absolute;top:5px;right:5px;bottom:5px}.platform-android .nav-bar-has-subheader .bar{background-image:none}.platform-android .bar .back-button .icon:before{font-size:24px}.platform-android .bar .title{font-size:19px;line-height:44px}.bar-light .button{border-color:#ddd;background-color:#fff;color:#444}.bar-light .button:hover{color:#444;text-decoration:none}.bar-light .button.activated,.bar-light .button.active{border-color:#ccc;background-color:#fafafa}.bar-light .button.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#444;font-size:17px}.bar-light .button.button-icon{border-color:transparent;background:0 0}.bar-stable .button{border-color:#b2b2b2;background-color:#f8f8f8;color:#444}.bar-stable .button:hover{color:#444;text-decoration:none}.bar-stable .button.activated,.bar-stable .button.active{border-color:#a2a2a2;background-color:#e5e5e5}.bar-stable .button.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#444;font-size:17px}.bar-stable .button.button-icon{border-color:transparent;background:0 0}.bar-positive .button{border-color:#0c60ee;background-color:#387ef5;color:#fff}.bar-positive .button:hover{color:#fff;text-decoration:none}.bar-positive .button.activated,.bar-positive .button.active{border-color:#0c60ee;background-color:#0c60ee}.bar-positive .button.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#fff;font-size:17px}.bar-positive .button.button-icon{border-color:transparent;background:0 0}.bar-calm .button{border-color:#0a9dc7;background-color:#11c1f3;color:#fff}.bar-calm .button:hover{color:#fff;text-decoration:none}.bar-calm .button.activated,.bar-calm .button.active{border-color:#0a9dc7;background-color:#0a9dc7}.bar-calm .button.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#fff;font-size:17px}.bar-calm .button.button-icon{border-color:transparent;background:0 0}.bar-assertive .button{border-color:#e42112;background-color:#ef473a;color:#fff}.bar-assertive .button:hover{color:#fff;text-decoration:none}.bar-assertive .button.activated,.bar-assertive .button.active{border-color:#e42112;background-color:#e42112}.bar-assertive .button.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#fff;font-size:17px}.bar-assertive .button.button-icon{border-color:transparent;background:0 0}.bar-balanced .button{border-color:#28a54c;background-color:#33cd5f;color:#fff}.bar-balanced .button:hover{color:#fff;text-decoration:none}.bar-balanced .button.activated,.bar-balanced .button.active{border-color:#28a54c;background-color:#28a54c}.bar-balanced .button.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#fff;font-size:17px}.bar-balanced .button.button-icon{border-color:transparent;background:0 0}.bar-energized .button{border-color:#e6b500;background-color:#ffc900;color:#fff}.bar-energized .button:hover{color:#fff;text-decoration:none}.bar-energized .button.activated,.bar-energized .button.active{border-color:#e6b500;background-color:#e6b500}.bar-energized .button.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#fff;font-size:17px}.bar-energized .button.button-icon{border-color:transparent;background:0 0}.bar-royal .button{border-color:#6b46e5;background-color:#886aea;color:#fff}.bar-royal .button:hover{color:#fff;text-decoration:none}.bar-royal .button.activated,.bar-royal .button.active{border-color:#6b46e5;background-color:#6b46e5}.bar-royal .button.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#fff;font-size:17px}.bar-royal .button.button-icon{border-color:transparent;background:0 0}.bar-dark .button{border-color:#111;background-color:#444;color:#fff}.bar-dark .button:hover{color:#fff;text-decoration:none}.bar-dark .button.activated,.bar-dark .button.active{border-color:#000;background-color:#262626}.bar-dark .button.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#fff;font-size:17px}.bar-dark .button.button-icon{border-color:transparent;background:0 0}.bar-header{top:0;border-top-width:0;border-bottom-width:1px}.bar-header.has-tabs-top,.tabs-top .bar-header{border-bottom-width:0;background-image:none}.bar-footer{bottom:0;border-top-width:1px;border-bottom-width:0;background-position:top;height:44px}.bar-footer.item-input-inset{position:absolute}.bar-footer .title{height:43px;line-height:44px}.bar-tabs{padding:0}.bar-subheader{top:44px;height:44px}.bar-subheader .title{height:43px;line-height:44px}.bar-subfooter{bottom:44px;height:44px}.bar-subfooter .title{height:43px;line-height:44px}.nav-bar-block{position:absolute;top:0;right:0;left:0;z-index:9}.bar .back-button.hide,.bar .buttons .hide{display:none}.nav-bar-tabs-top .bar{background-image:none}.tabs{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-direction:normal;-webkit-box-orient:horizontal;-webkit-flex-direction:horizontal;-moz-flex-direction:horizontal;-ms-flex-direction:horizontal;flex-direction:horizontal;-webkit-box-pack:center;-ms-flex-pack:center;-webkit-justify-content:center;-moz-justify-content:center;justify-content:center;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);border-color:#b2b2b2;background-color:#f8f8f8;background-image:linear-gradient(0deg,#b2b2b2,#b2b2b2 50%,transparent 50%);color:#444;position:absolute;bottom:0;z-index:5;width:100%;height:49px;border-style:solid;border-top-width:1px;background-size:0;line-height:49px}.tabs .tab-item .badge{background-color:#444;color:#f8f8f8}@media (min--moz-device-pixel-ratio:1.5),(-webkit-min-device-pixel-ratio:1.5),(min-device-pixel-ratio:1.5),(min-resolution:144dpi),(min-resolution:1.5dppx){.tabs{padding-top:2px;border-top:none!important;border-bottom:none;background-position:top;background-size:100% 1px;background-repeat:no-repeat}}.tabs-light>.tabs,.tabs.tabs-light{border-color:#ddd;background-color:#fff;background-image:linear-gradient(0deg,#ddd,#ddd 50%,transparent 50%);color:#444}.tabs-light>.tabs .tab-item .badge,.tabs.tabs-light .tab-item .badge{background-color:#444;color:#fff}.tabs-stable>.tabs,.tabs.tabs-stable{border-color:#b2b2b2;background-color:#f8f8f8;background-image:linear-gradient(0deg,#b2b2b2,#b2b2b2 50%,transparent 50%);color:#444}.tabs-stable>.tabs .tab-item .badge,.tabs.tabs-stable .tab-item .badge{background-color:#444;color:#f8f8f8}.tabs-positive>.tabs,.tabs.tabs-positive{border-color:#0c60ee;background-color:#387ef5;background-image:linear-gradient(0deg,#0c60ee,#0c60ee 50%,transparent 50%);color:#fff}.tabs-positive>.tabs .tab-item .badge,.tabs.tabs-positive .tab-item .badge{background-color:#fff;color:#387ef5}.tabs-calm>.tabs,.tabs.tabs-calm{border-color:#0a9dc7;background-color:#11c1f3;background-image:linear-gradient(0deg,#0a9dc7,#0a9dc7 50%,transparent 50%);color:#fff}.tabs-calm>.tabs .tab-item .badge,.tabs.tabs-calm .tab-item .badge{background-color:#fff;color:#11c1f3}.tabs-assertive>.tabs,.tabs.tabs-assertive{border-color:#e42112;background-color:#ef473a;background-image:linear-gradient(0deg,#e42112,#e42112 50%,transparent 50%);color:#fff}.tabs-assertive>.tabs .tab-item .badge,.tabs.tabs-assertive .tab-item .badge{background-color:#fff;color:#ef473a}.tabs-balanced>.tabs,.tabs.tabs-balanced{border-color:#28a54c;background-color:#33cd5f;background-image:linear-gradient(0deg,#28a54c,#28a54c 50%,transparent 50%);color:#fff}.tabs-balanced>.tabs .tab-item .badge,.tabs.tabs-balanced .tab-item .badge{background-color:#fff;color:#33cd5f}.tabs-energized>.tabs,.tabs.tabs-energized{border-color:#e6b500;background-color:#ffc900;background-image:linear-gradient(0deg,#e6b500,#e6b500 50%,transparent 50%);color:#fff}.tabs-energized>.tabs .tab-item .badge,.tabs.tabs-energized .tab-item .badge{background-color:#fff;color:#ffc900}.tabs-royal>.tabs,.tabs.tabs-royal{border-color:#6b46e5;background-color:#886aea;background-image:linear-gradient(0deg,#6b46e5,#6b46e5 50%,transparent 50%);color:#fff}.tabs-royal>.tabs .tab-item .badge,.tabs.tabs-royal .tab-item .badge{background-color:#fff;color:#886aea}.tabs-dark>.tabs,.tabs.tabs-dark{border-color:#111;background-color:#444;background-image:linear-gradient(0deg,#111,#111 50%,transparent 50%);color:#fff}.tabs-dark>.tabs .tab-item .badge,.tabs.tabs-dark .tab-item .badge{background-color:#fff;color:#444}.tabs-striped .tabs{background-color:#fff;background-image:none;border:none;border-bottom:1px solid #ddd;padding-top:2px}.tabs-striped .tab-item.activated,.tabs-striped .tab-item.active,.tabs-striped .tab-item.tab-item-active{margin-top:-2px;border-style:solid;border-width:2px 0 0 0;border-color:#444}.tabs-striped .tab-item.activated .badge,.tabs-striped .tab-item.active .badge,.tabs-striped .tab-item.tab-item-active .badge{top:2px;opacity:1}.tabs-striped.tabs-light .tabs{background-color:#fff}.tabs-striped.tabs-light .tab-item{color:rgba(68,68,68,.4);opacity:1}.tabs-striped.tabs-light .tab-item .badge{opacity:.4}.tabs-striped.tabs-light .tab-item.activated,.tabs-striped.tabs-light .tab-item.active,.tabs-striped.tabs-light .tab-item.tab-item-active{margin-top:-2px;color:#444;border-style:solid;border-width:2px 0 0 0;border-color:#444}.tabs-striped.tabs-stable .tabs{background-color:#f8f8f8}.tabs-striped.tabs-stable .tab-item{color:rgba(68,68,68,.4);opacity:1}.tabs-striped.tabs-stable .tab-item .badge{opacity:.4}.tabs-striped.tabs-stable .tab-item.activated,.tabs-striped.tabs-stable .tab-item.active,.tabs-striped.tabs-stable .tab-item.tab-item-active{margin-top:-2px;color:#444;border-style:solid;border-width:2px 0 0 0;border-color:#444}.tabs-striped.tabs-positive .tabs{background-color:#387ef5}.tabs-striped.tabs-positive .tab-item{color:rgba(255,255,255,.4);opacity:1}.tabs-striped.tabs-positive .tab-item .badge{opacity:.4}.tabs-striped.tabs-positive .tab-item.activated,.tabs-striped.tabs-positive .tab-item.active,.tabs-striped.tabs-positive .tab-item.tab-item-active{margin-top:-2px;color:#fff;border-style:solid;border-width:2px 0 0 0;border-color:#fff}.tabs-striped.tabs-calm .tabs{background-color:#11c1f3}.tabs-striped.tabs-calm .tab-item{color:rgba(255,255,255,.4);opacity:1}.tabs-striped.tabs-calm .tab-item .badge{opacity:.4}.tabs-striped.tabs-calm .tab-item.activated,.tabs-striped.tabs-calm .tab-item.active,.tabs-striped.tabs-calm .tab-item.tab-item-active{margin-top:-2px;color:#fff;border-style:solid;border-width:2px 0 0 0;border-color:#fff}.tabs-striped.tabs-assertive .tabs{background-color:#ef473a}.tabs-striped.tabs-assertive .tab-item{color:rgba(255,255,255,.4);opacity:1}.tabs-striped.tabs-assertive .tab-item .badge{opacity:.4}.tabs-striped.tabs-assertive .tab-item.activated,.tabs-striped.tabs-assertive .tab-item.active,.tabs-striped.tabs-assertive .tab-item.tab-item-active{margin-top:-2px;color:#fff;border-style:solid;border-width:2px 0 0 0;border-color:#fff}.tabs-striped.tabs-balanced .tabs{background-color:#33cd5f}.tabs-striped.tabs-balanced .tab-item{color:rgba(255,255,255,.4);opacity:1}.tabs-striped.tabs-balanced .tab-item .badge{opacity:.4}.tabs-striped.tabs-balanced .tab-item.activated,.tabs-striped.tabs-balanced .tab-item.active,.tabs-striped.tabs-balanced .tab-item.tab-item-active{margin-top:-2px;color:#fff;border-style:solid;border-width:2px 0 0 0;border-color:#fff}.tabs-striped.tabs-energized .tabs{background-color:#ffc900}.tabs-striped.tabs-energized .tab-item{color:rgba(255,255,255,.4);opacity:1}.tabs-striped.tabs-energized .tab-item .badge{opacity:.4}.tabs-striped.tabs-energized .tab-item.activated,.tabs-striped.tabs-energized .tab-item.active,.tabs-striped.tabs-energized .tab-item.tab-item-active{margin-top:-2px;color:#fff;border-style:solid;border-width:2px 0 0 0;border-color:#fff}.tabs-striped.tabs-royal .tabs{background-color:#886aea}.tabs-striped.tabs-royal .tab-item{color:rgba(255,255,255,.4);opacity:1}.tabs-striped.tabs-royal .tab-item .badge{opacity:.4}.tabs-striped.tabs-royal .tab-item.activated,.tabs-striped.tabs-royal .tab-item.active,.tabs-striped.tabs-royal .tab-item.tab-item-active{margin-top:-2px;color:#fff;border-style:solid;border-width:2px 0 0 0;border-color:#fff}.tabs-striped.tabs-dark .tabs{background-color:#444}.tabs-striped.tabs-dark .tab-item{color:rgba(255,255,255,.4);opacity:1}.tabs-striped.tabs-dark .tab-item .badge{opacity:.4}.tabs-striped.tabs-dark .tab-item.activated,.tabs-striped.tabs-dark .tab-item.active,.tabs-striped.tabs-dark .tab-item.tab-item-active{margin-top:-2px;color:#fff;border-style:solid;border-width:2px 0 0 0;border-color:#fff}.tabs-striped.tabs-top .tab-item.activated .badge,.tabs-striped.tabs-top .tab-item.active .badge,.tabs-striped.tabs-top .tab-item.tab-item-active .badge{top:4%}.tabs-striped.tabs-background-light .tabs{background-color:#fff;background-image:none}.tabs-striped.tabs-background-stable .tabs{background-color:#f8f8f8;background-image:none}.tabs-striped.tabs-background-positive .tabs{background-color:#387ef5;background-image:none}.tabs-striped.tabs-background-calm .tabs{background-color:#11c1f3;background-image:none}.tabs-striped.tabs-background-assertive .tabs{background-color:#ef473a;background-image:none}.tabs-striped.tabs-background-balanced .tabs{background-color:#33cd5f;background-image:none}.tabs-striped.tabs-background-energized .tabs{background-color:#ffc900;background-image:none}.tabs-striped.tabs-background-royal .tabs{background-color:#886aea;background-image:none}.tabs-striped.tabs-background-dark .tabs{background-color:#444;background-image:none}.tabs-striped.tabs-color-light .tab-item{color:rgba(255,255,255,.4);opacity:1}.tabs-striped.tabs-color-light .tab-item .badge{opacity:.4}.tabs-striped.tabs-color-light .tab-item.activated,.tabs-striped.tabs-color-light .tab-item.active,.tabs-striped.tabs-color-light .tab-item.tab-item-active{margin-top:-2px;color:#fff;border:0 solid #fff;border-top-width:2px}.tabs-striped.tabs-color-light .tab-item.activated .badge,.tabs-striped.tabs-color-light .tab-item.active .badge,.tabs-striped.tabs-color-light .tab-item.tab-item-active .badge{top:2px;opacity:1}.tabs-striped.tabs-color-stable .tab-item{color:rgba(248,248,248,.4);opacity:1}.tabs-striped.tabs-color-stable .tab-item .badge{opacity:.4}.tabs-striped.tabs-color-stable .tab-item.activated,.tabs-striped.tabs-color-stable .tab-item.active,.tabs-striped.tabs-color-stable .tab-item.tab-item-active{margin-top:-2px;color:#f8f8f8;border:0 solid #f8f8f8;border-top-width:2px}.tabs-striped.tabs-color-stable .tab-item.activated .badge,.tabs-striped.tabs-color-stable .tab-item.active .badge,.tabs-striped.tabs-color-stable .tab-item.tab-item-active .badge{top:2px;opacity:1}.tabs-striped.tabs-color-positive .tab-item{color:rgba(56,126,245,.4);opacity:1}.tabs-striped.tabs-color-positive .tab-item .badge{opacity:.4}.tabs-striped.tabs-color-positive .tab-item.activated,.tabs-striped.tabs-color-positive .tab-item.active,.tabs-striped.tabs-color-positive .tab-item.tab-item-active{margin-top:-2px;color:#387ef5;border:0 solid #387ef5;border-top-width:2px}.tabs-striped.tabs-color-positive .tab-item.activated .badge,.tabs-striped.tabs-color-positive .tab-item.active .badge,.tabs-striped.tabs-color-positive .tab-item.tab-item-active .badge{top:2px;opacity:1}.tabs-striped.tabs-color-calm .tab-item{color:rgba(17,193,243,.4);opacity:1}.tabs-striped.tabs-color-calm .tab-item .badge{opacity:.4}.tabs-striped.tabs-color-calm .tab-item.activated,.tabs-striped.tabs-color-calm .tab-item.active,.tabs-striped.tabs-color-calm .tab-item.tab-item-active{margin-top:-2px;color:#11c1f3;border:0 solid #11c1f3;border-top-width:2px}.tabs-striped.tabs-color-calm .tab-item.activated .badge,.tabs-striped.tabs-color-calm .tab-item.active .badge,.tabs-striped.tabs-color-calm .tab-item.tab-item-active .badge{top:2px;opacity:1}.tabs-striped.tabs-color-assertive .tab-item{color:rgba(239,71,58,.4);opacity:1}.tabs-striped.tabs-color-assertive .tab-item .badge{opacity:.4}.tabs-striped.tabs-color-assertive .tab-item.activated,.tabs-striped.tabs-color-assertive .tab-item.active,.tabs-striped.tabs-color-assertive .tab-item.tab-item-active{margin-top:-2px;color:#ef473a;border:0 solid #ef473a;border-top-width:2px}.tabs-striped.tabs-color-assertive .tab-item.activated .badge,.tabs-striped.tabs-color-assertive .tab-item.active .badge,.tabs-striped.tabs-color-assertive .tab-item.tab-item-active .badge{top:2px;opacity:1}.tabs-striped.tabs-color-balanced .tab-item{color:rgba(51,205,95,.4);opacity:1}.tabs-striped.tabs-color-balanced .tab-item .badge{opacity:.4}.tabs-striped.tabs-color-balanced .tab-item.activated,.tabs-striped.tabs-color-balanced .tab-item.active,.tabs-striped.tabs-color-balanced .tab-item.tab-item-active{margin-top:-2px;color:#33cd5f;border:0 solid #33cd5f;border-top-width:2px}.tabs-striped.tabs-color-balanced .tab-item.activated .badge,.tabs-striped.tabs-color-balanced .tab-item.active .badge,.tabs-striped.tabs-color-balanced .tab-item.tab-item-active .badge{top:2px;opacity:1}.tabs-striped.tabs-color-energized .tab-item{color:rgba(255,201,0,.4);opacity:1}.tabs-striped.tabs-color-energized .tab-item .badge{opacity:.4}.tabs-striped.tabs-color-energized .tab-item.activated,.tabs-striped.tabs-color-energized .tab-item.active,.tabs-striped.tabs-color-energized .tab-item.tab-item-active{margin-top:-2px;color:#ffc900;border:0 solid #ffc900;border-top-width:2px}.tabs-striped.tabs-color-energized .tab-item.activated .badge,.tabs-striped.tabs-color-energized .tab-item.active .badge,.tabs-striped.tabs-color-energized .tab-item.tab-item-active .badge{top:2px;opacity:1}.tabs-striped.tabs-color-royal .tab-item{color:rgba(136,106,234,.4);opacity:1}.tabs-striped.tabs-color-royal .tab-item .badge{opacity:.4}.tabs-striped.tabs-color-royal .tab-item.activated,.tabs-striped.tabs-color-royal .tab-item.active,.tabs-striped.tabs-color-royal .tab-item.tab-item-active{margin-top:-2px;color:#886aea;border:0 solid #886aea;border-top-width:2px}.tabs-striped.tabs-color-royal .tab-item.activated .badge,.tabs-striped.tabs-color-royal .tab-item.active .badge,.tabs-striped.tabs-color-royal .tab-item.tab-item-active .badge{top:2px;opacity:1}.tabs-striped.tabs-color-dark .tab-item{color:rgba(68,68,68,.4);opacity:1}.tabs-striped.tabs-color-dark .tab-item .badge{opacity:.4}.tabs-striped.tabs-color-dark .tab-item.activated,.tabs-striped.tabs-color-dark .tab-item.active,.tabs-striped.tabs-color-dark .tab-item.tab-item-active{margin-top:-2px;color:#444;border:0 solid #444;border-top-width:2px}.tabs-striped.tabs-color-dark .tab-item.activated .badge,.tabs-striped.tabs-color-dark .tab-item.active .badge,.tabs-striped.tabs-color-dark .tab-item.tab-item-active .badge{top:2px;opacity:1}.tabs-background-light .tabs,.tabs-background-light>.tabs{background-color:#fff;background-image:linear-gradient(0deg,#ddd,#ddd 50%,transparent 50%);border-color:#ddd}.tabs-background-stable .tabs,.tabs-background-stable>.tabs{background-color:#f8f8f8;background-image:linear-gradient(0deg,#b2b2b2,#b2b2b2 50%,transparent 50%);border-color:#b2b2b2}.tabs-background-positive .tabs,.tabs-background-positive>.tabs{background-color:#387ef5;background-image:linear-gradient(0deg,#0c60ee,#0c60ee 50%,transparent 50%);border-color:#0c60ee}.tabs-background-calm .tabs,.tabs-background-calm>.tabs{background-color:#11c1f3;background-image:linear-gradient(0deg,#0a9dc7,#0a9dc7 50%,transparent 50%);border-color:#0a9dc7}.tabs-background-assertive .tabs,.tabs-background-assertive>.tabs{background-color:#ef473a;background-image:linear-gradient(0deg,#e42112,#e42112 50%,transparent 50%);border-color:#e42112}.tabs-background-balanced .tabs,.tabs-background-balanced>.tabs{background-color:#33cd5f;background-image:linear-gradient(0deg,#28a54c,#28a54c 50%,transparent 50%);border-color:#28a54c}.tabs-background-energized .tabs,.tabs-background-energized>.tabs{background-color:#ffc900;background-image:linear-gradient(0deg,#e6b500,#e6b500 50%,transparent 50%);border-color:#e6b500}.tabs-background-royal .tabs,.tabs-background-royal>.tabs{background-color:#886aea;background-image:linear-gradient(0deg,#6b46e5,#6b46e5 50%,transparent 50%);border-color:#6b46e5}.tabs-background-dark .tabs,.tabs-background-dark>.tabs{background-color:#444;background-image:linear-gradient(0deg,#111,#111 50%,transparent 50%);border-color:#111}.tabs-color-light .tab-item{color:rgba(255,255,255,.4);opacity:1}.tabs-color-light .tab-item .badge{opacity:.4}.tabs-color-light .tab-item.activated,.tabs-color-light .tab-item.active,.tabs-color-light .tab-item.tab-item-active{color:#fff;border:0 solid #fff}.tabs-color-light .tab-item.activated .badge,.tabs-color-light .tab-item.active .badge,.tabs-color-light .tab-item.tab-item-active .badge{opacity:1}.tabs-color-stable .tab-item{color:rgba(248,248,248,.4);opacity:1}.tabs-color-stable .tab-item .badge{opacity:.4}.tabs-color-stable .tab-item.activated,.tabs-color-stable .tab-item.active,.tabs-color-stable .tab-item.tab-item-active{color:#f8f8f8;border:0 solid #f8f8f8}.tabs-color-stable .tab-item.activated .badge,.tabs-color-stable .tab-item.active .badge,.tabs-color-stable .tab-item.tab-item-active .badge{opacity:1}.tabs-color-positive .tab-item{color:rgba(56,126,245,.4);opacity:1}.tabs-color-positive .tab-item .badge{opacity:.4}.tabs-color-positive .tab-item.activated,.tabs-color-positive .tab-item.active,.tabs-color-positive .tab-item.tab-item-active{color:#387ef5;border:0 solid #387ef5}.tabs-color-positive .tab-item.activated .badge,.tabs-color-positive .tab-item.active .badge,.tabs-color-positive .tab-item.tab-item-active .badge{opacity:1}.tabs-color-calm .tab-item{color:rgba(17,193,243,.4);opacity:1}.tabs-color-calm .tab-item .badge{opacity:.4}.tabs-color-calm .tab-item.activated,.tabs-color-calm .tab-item.active,.tabs-color-calm .tab-item.tab-item-active{color:#11c1f3;border:0 solid #11c1f3}.tabs-color-calm .tab-item.activated .badge,.tabs-color-calm .tab-item.active .badge,.tabs-color-calm .tab-item.tab-item-active .badge{opacity:1}.tabs-color-assertive .tab-item{color:rgba(239,71,58,.4);opacity:1}.tabs-color-assertive .tab-item .badge{opacity:.4}.tabs-color-assertive .tab-item.activated,.tabs-color-assertive .tab-item.active,.tabs-color-assertive .tab-item.tab-item-active{color:#ef473a;border:0 solid #ef473a}.tabs-color-assertive .tab-item.activated .badge,.tabs-color-assertive .tab-item.active .badge,.tabs-color-assertive .tab-item.tab-item-active .badge{opacity:1}.tabs-color-balanced .tab-item{color:rgba(51,205,95,.4);opacity:1}.tabs-color-balanced .tab-item .badge{opacity:.4}.tabs-color-balanced .tab-item.activated,.tabs-color-balanced .tab-item.active,.tabs-color-balanced .tab-item.tab-item-active{color:#33cd5f;border:0 solid #33cd5f}.tabs-color-balanced .tab-item.activated .badge,.tabs-color-balanced .tab-item.active .badge,.tabs-color-balanced .tab-item.tab-item-active .badge{opacity:1}.tabs-color-energized .tab-item{color:rgba(255,201,0,.4);opacity:1}.tabs-color-energized .tab-item .badge{opacity:.4}.tabs-color-energized .tab-item.activated,.tabs-color-energized .tab-item.active,.tabs-color-energized .tab-item.tab-item-active{color:#ffc900;border:0 solid #ffc900}.tabs-color-energized .tab-item.activated .badge,.tabs-color-energized .tab-item.active .badge,.tabs-color-energized .tab-item.tab-item-active .badge{opacity:1}.tabs-color-royal .tab-item{color:rgba(136,106,234,.4);opacity:1}.tabs-color-royal .tab-item .badge{opacity:.4}.tabs-color-royal .tab-item.activated,.tabs-color-royal .tab-item.active,.tabs-color-royal .tab-item.tab-item-active{color:#886aea;border:0 solid #886aea}.tabs-color-royal .tab-item.activated .badge,.tabs-color-royal .tab-item.active .badge,.tabs-color-royal .tab-item.tab-item-active .badge{opacity:1}.tabs-color-dark .tab-item{color:rgba(68,68,68,.4);opacity:1}.tabs-color-dark .tab-item .badge{opacity:.4}.tabs-color-dark .tab-item.activated,.tabs-color-dark .tab-item.active,.tabs-color-dark .tab-item.tab-item-active{color:#444;border:0 solid #444}.tabs-color-dark .tab-item.activated .badge,.tabs-color-dark .tab-item.active .badge,.tabs-color-dark .tab-item.tab-item-active .badge{opacity:1}ion-tabs.tabs-color-active-light .tab-item{color:#444}ion-tabs.tabs-color-active-light .tab-item.activated,ion-tabs.tabs-color-active-light .tab-item.active,ion-tabs.tabs-color-active-light .tab-item.tab-item-active{color:#fff}ion-tabs.tabs-striped.tabs-color-active-light .tab-item.activated,ion-tabs.tabs-striped.tabs-color-active-light .tab-item.active,ion-tabs.tabs-striped.tabs-color-active-light .tab-item.tab-item-active{border-color:#fff;color:#fff}ion-tabs.tabs-color-active-stable .tab-item{color:#444}ion-tabs.tabs-color-active-stable .tab-item.activated,ion-tabs.tabs-color-active-stable .tab-item.active,ion-tabs.tabs-color-active-stable .tab-item.tab-item-active{color:#f8f8f8}ion-tabs.tabs-striped.tabs-color-active-stable .tab-item.activated,ion-tabs.tabs-striped.tabs-color-active-stable .tab-item.active,ion-tabs.tabs-striped.tabs-color-active-stable .tab-item.tab-item-active{border-color:#f8f8f8;color:#f8f8f8}ion-tabs.tabs-color-active-positive .tab-item{color:#444}ion-tabs.tabs-color-active-positive .tab-item.activated,ion-tabs.tabs-color-active-positive .tab-item.active,ion-tabs.tabs-color-active-positive .tab-item.tab-item-active{color:#387ef5}ion-tabs.tabs-striped.tabs-color-active-positive .tab-item.activated,ion-tabs.tabs-striped.tabs-color-active-positive .tab-item.active,ion-tabs.tabs-striped.tabs-color-active-positive .tab-item.tab-item-active{border-color:#387ef5;color:#387ef5}ion-tabs.tabs-color-active-calm .tab-item{color:#444}ion-tabs.tabs-color-active-calm .tab-item.activated,ion-tabs.tabs-color-active-calm .tab-item.active,ion-tabs.tabs-color-active-calm .tab-item.tab-item-active{color:#11c1f3}ion-tabs.tabs-striped.tabs-color-active-calm .tab-item.activated,ion-tabs.tabs-striped.tabs-color-active-calm .tab-item.active,ion-tabs.tabs-striped.tabs-color-active-calm .tab-item.tab-item-active{border-color:#11c1f3;color:#11c1f3}ion-tabs.tabs-color-active-assertive .tab-item{color:#444}ion-tabs.tabs-color-active-assertive .tab-item.activated,ion-tabs.tabs-color-active-assertive .tab-item.active,ion-tabs.tabs-color-active-assertive .tab-item.tab-item-active{color:#ef473a}ion-tabs.tabs-striped.tabs-color-active-assertive .tab-item.activated,ion-tabs.tabs-striped.tabs-color-active-assertive .tab-item.active,ion-tabs.tabs-striped.tabs-color-active-assertive .tab-item.tab-item-active{border-color:#ef473a;color:#ef473a}ion-tabs.tabs-color-active-balanced .tab-item{color:#444}ion-tabs.tabs-color-active-balanced .tab-item.activated,ion-tabs.tabs-color-active-balanced .tab-item.active,ion-tabs.tabs-color-active-balanced .tab-item.tab-item-active{color:#33cd5f}ion-tabs.tabs-striped.tabs-color-active-balanced .tab-item.activated,ion-tabs.tabs-striped.tabs-color-active-balanced .tab-item.active,ion-tabs.tabs-striped.tabs-color-active-balanced .tab-item.tab-item-active{border-color:#33cd5f;color:#33cd5f}ion-tabs.tabs-color-active-energized .tab-item{color:#444}ion-tabs.tabs-color-active-energized .tab-item.activated,ion-tabs.tabs-color-active-energized .tab-item.active,ion-tabs.tabs-color-active-energized .tab-item.tab-item-active{color:#ffc900}ion-tabs.tabs-striped.tabs-color-active-energized .tab-item.activated,ion-tabs.tabs-striped.tabs-color-active-energized .tab-item.active,ion-tabs.tabs-striped.tabs-color-active-energized .tab-item.tab-item-active{border-color:#ffc900;color:#ffc900}ion-tabs.tabs-color-active-royal .tab-item{color:#444}ion-tabs.tabs-color-active-royal .tab-item.activated,ion-tabs.tabs-color-active-royal .tab-item.active,ion-tabs.tabs-color-active-royal .tab-item.tab-item-active{color:#886aea}ion-tabs.tabs-striped.tabs-color-active-royal .tab-item.activated,ion-tabs.tabs-striped.tabs-color-active-royal .tab-item.active,ion-tabs.tabs-striped.tabs-color-active-royal .tab-item.tab-item-active{border-color:#886aea;color:#886aea}ion-tabs.tabs-color-active-dark .tab-item{color:#fff}ion-tabs.tabs-color-active-dark .tab-item.activated,ion-tabs.tabs-color-active-dark .tab-item.active,ion-tabs.tabs-color-active-dark .tab-item.tab-item-active{color:#444}ion-tabs.tabs-striped.tabs-color-active-dark .tab-item.activated,ion-tabs.tabs-striped.tabs-color-active-dark .tab-item.active,ion-tabs.tabs-striped.tabs-color-active-dark .tab-item.tab-item-active{border-color:#444;color:#444}.tabs-top.tabs-striped{padding-bottom:0}.tabs-top.tabs-striped .tab-item{background:0 0;-webkit-transition:color .1s ease;-moz-transition:color .1s ease;-ms-transition:color .1s ease;-o-transition:color .1s ease;transition:color .1s ease}.tabs-top.tabs-striped .tab-item.activated,.tabs-top.tabs-striped .tab-item.active,.tabs-top.tabs-striped .tab-item.tab-item-active{margin-top:1px;border-width:0 0 2px 0!important;border-style:solid}.tabs-top.tabs-striped .tab-item.activated>.badge,.tabs-top.tabs-striped .tab-item.activated>i,.tabs-top.tabs-striped .tab-item.active>.badge,.tabs-top.tabs-striped .tab-item.active>i,.tabs-top.tabs-striped .tab-item.tab-item-active>.badge,.tabs-top.tabs-striped .tab-item.tab-item-active>i{margin-top:-1px}.tabs-top.tabs-striped .tab-item .badge{-webkit-transition:color .2s ease;-moz-transition:color .2s ease;-ms-transition:color .2s ease;-o-transition:color .2s ease;transition:color .2s ease}.tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.activated .tab-title,.tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.activated i,.tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.active .tab-title,.tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.active i,.tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.tab-item-active .tab-title,.tabs-top.tabs-striped:not(.tabs-icon-left):not(.tabs-icon-top) .tab-item.tab-item-active i{display:block;margin-top:-1px}.tabs-top.tabs-striped.tabs-icon-left .tab-item{margin-top:1px}.tabs-top.tabs-striped.tabs-icon-left .tab-item.activated .tab-title,.tabs-top.tabs-striped.tabs-icon-left .tab-item.activated i,.tabs-top.tabs-striped.tabs-icon-left .tab-item.active .tab-title,.tabs-top.tabs-striped.tabs-icon-left .tab-item.active i,.tabs-top.tabs-striped.tabs-icon-left .tab-item.tab-item-active .tab-title,.tabs-top.tabs-striped.tabs-icon-left .tab-item.tab-item-active i{margin-top:-.1em}.tabs-top>.tabs,.tabs.tabs-top{top:44px;padding-top:0;background-position:bottom;border-top-width:0;border-bottom-width:1px}.tabs-top>.tabs .tab-item.activated .badge,.tabs-top>.tabs .tab-item.active .badge,.tabs-top>.tabs .tab-item.tab-item-active .badge,.tabs.tabs-top .tab-item.activated .badge,.tabs.tabs-top .tab-item.active .badge,.tabs.tabs-top .tab-item.tab-item-active .badge{top:4%}.tabs-top~.bar-header{border-bottom-width:0}.tab-item{-webkit-box-flex:1;-webkit-flex:1;-moz-box-flex:1;-moz-flex:1;-ms-flex:1;flex:1;display:block;overflow:hidden;max-width:150px;height:100%;color:inherit;text-align:center;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;font-weight:400;font-size:14px;font-family:"-apple-system","Helvetica Neue",Roboto,"Segoe UI",sans-serif;opacity:.7}.tab-item:hover{cursor:pointer}.tab-item.tab-hidden,.tabs-item-hide>.tabs,.tabs.tabs-item-hide{display:none}.tabs-icon-bottom.tabs .tab-item,.tabs-icon-bottom>.tabs .tab-item,.tabs-icon-top.tabs .tab-item,.tabs-icon-top>.tabs .tab-item{font-size:10px;line-height:14px}.tab-item .icon{display:block;margin:0 auto;height:32px;font-size:32px}.tabs-icon-left.tabs .tab-item,.tabs-icon-left>.tabs .tab-item,.tabs-icon-right.tabs .tab-item,.tabs-icon-right>.tabs .tab-item{font-size:10px}.tabs-icon-left.tabs .tab-item .icon,.tabs-icon-left.tabs .tab-item .tab-title,.tabs-icon-left>.tabs .tab-item .icon,.tabs-icon-left>.tabs .tab-item .tab-title,.tabs-icon-right.tabs .tab-item .icon,.tabs-icon-right.tabs .tab-item .tab-title,.tabs-icon-right>.tabs .tab-item .icon,.tabs-icon-right>.tabs .tab-item .tab-title{display:inline-block;vertical-align:top;margin-top:-.1em}.tabs-icon-left.tabs .tab-item .icon:before,.tabs-icon-left.tabs .tab-item .tab-title:before,.tabs-icon-left>.tabs .tab-item .icon:before,.tabs-icon-left>.tabs .tab-item .tab-title:before,.tabs-icon-right.tabs .tab-item .icon:before,.tabs-icon-right.tabs .tab-item .tab-title:before,.tabs-icon-right>.tabs .tab-item .icon:before,.tabs-icon-right>.tabs .tab-item .tab-title:before{font-size:24px;line-height:49px}.tabs-icon-left.tabs .tab-item .icon,.tabs-icon-left>.tabs .tab-item .icon{padding-right:3px}.tabs-icon-right.tabs .tab-item .icon,.tabs-icon-right>.tabs .tab-item .icon{padding-left:3px}.tabs-icon-only.tabs .icon,.tabs-icon-only>.tabs .icon{line-height:inherit}.tab-item.has-badge{position:relative}.tab-item .badge{position:absolute;top:4%;right:33%;right:calc(50% - 26px);padding:1px 6px;height:auto;font-size:12px;line-height:16px}.tab-item.activated,.tab-item.active,.tab-item.tab-item-active{opacity:1}.tab-item.activated.tab-item-light,.tab-item.active.tab-item-light,.tab-item.tab-item-active.tab-item-light{color:#fff}.tab-item.activated.tab-item-stable,.tab-item.active.tab-item-stable,.tab-item.tab-item-active.tab-item-stable{color:#f8f8f8}.tab-item.activated.tab-item-positive,.tab-item.active.tab-item-positive,.tab-item.tab-item-active.tab-item-positive{color:#387ef5}.tab-item.activated.tab-item-calm,.tab-item.active.tab-item-calm,.tab-item.tab-item-active.tab-item-calm{color:#11c1f3}.tab-item.activated.tab-item-assertive,.tab-item.active.tab-item-assertive,.tab-item.tab-item-active.tab-item-assertive{color:#ef473a}.tab-item.activated.tab-item-balanced,.tab-item.active.tab-item-balanced,.tab-item.tab-item-active.tab-item-balanced{color:#33cd5f}.tab-item.activated.tab-item-energized,.tab-item.active.tab-item-energized,.tab-item.tab-item-active.tab-item-energized{color:#ffc900}.tab-item.activated.tab-item-royal,.tab-item.active.tab-item-royal,.tab-item.tab-item-active.tab-item-royal{color:#886aea}.tab-item.activated.tab-item-dark,.tab-item.active.tab-item-dark,.tab-item.tab-item-active.tab-item-dark{color:#444}.item.tabs{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;padding:0}.item.tabs .icon:before{position:relative}.tab-item.disabled,.tab-item[disabled]{opacity:.4;cursor:default;pointer-events:none}.nav-bar-tabs-top.hide~.view-container .tabs-top .tabs{top:0}.pane[hide-nav-bar=true] .has-tabs-top{top:49px}.menu{position:absolute;top:0;bottom:0;z-index:0;overflow:hidden;min-height:100%;max-height:100%;width:275px;background-color:#fff}.menu .scroll-content{z-index:10}.menu .bar-header{z-index:11}.menu-content{-webkit-transform:none;transform:none;box-shadow:-1px 0 2px rgba(0,0,0,.2),1px 0 2px rgba(0,0,0,.2)}.menu-open .menu-content .pane,.menu-open .menu-content .scroll-content,.menu-open .menu-content .scroll-content .scroll{pointer-events:none}.menu-open .menu-content .scroll-content:not(.overflow-scroll){overflow:hidden}.grade-b .menu-content,.grade-c .menu-content{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;right:-1px;left:-1px;border-right:1px solid #ccc;border-left:1px solid #ccc;box-shadow:none}.menu-left{left:0}.menu-right{right:0}.aside-open.aside-resizing .menu-right{display:none}.menu-animated{-webkit-transition:-webkit-transform 200ms ease;transition:transform 200ms ease}.modal-backdrop,.modal-backdrop-bg{position:fixed;top:0;left:0;z-index:10;width:100%;height:100%}.modal-backdrop-bg{pointer-events:none}.modal{display:block;position:absolute;top:0;z-index:10;overflow:hidden;min-height:100%;width:100%;background-color:#fff}@media (min-width:680px){.modal{top:20%;right:20%;bottom:20%;left:20%;min-height:240px;width:60%}.modal.ng-leave-active{bottom:0}.platform-ios.platform-cordova .modal-wrapper .modal .bar-header:not(.bar-subheader){height:44px}.platform-ios.platform-cordova .modal-wrapper .modal .bar-header:not(.bar-subheader)>*{margin-top:0}.platform-ios.platform-cordova .modal-wrapper .modal .bar-subheader,.platform-ios.platform-cordova .modal-wrapper .modal .has-header,.platform-ios.platform-cordova .modal-wrapper .modal .tabs-top>.tabs,.platform-ios.platform-cordova .modal-wrapper .modal .tabs.tabs-top{top:44px}.platform-ios.platform-cordova .modal-wrapper .modal .has-subheader{top:88px}.platform-ios.platform-cordova .modal-wrapper .modal .has-header.has-tabs-top{top:93px}.platform-ios.platform-cordova .modal-wrapper .modal .has-header.has-subheader.has-tabs-top{top:137px}.modal-backdrop-bg{-webkit-transition:opacity 300ms ease-in-out;transition:opacity 300ms ease-in-out;background-color:#000;opacity:0}.active .modal-backdrop-bg{opacity:.5}}.modal-open{pointer-events:none}.modal-open .modal,.modal-open .modal-backdrop{pointer-events:auto}.modal-open.loading-active .modal,.modal-open.loading-active .modal-backdrop{pointer-events:none}.popover-backdrop{position:fixed;top:0;left:0;z-index:10;width:100%;height:100%;background-color:transparent}.popover-backdrop.active{background-color:rgba(0,0,0,.1)}.popover{position:absolute;top:25%;left:50%;z-index:10;display:block;margin-top:12px;margin-left:-110px;height:280px;width:220px;background-color:#fff;box-shadow:0 1px 3px rgba(0,0,0,.4);opacity:0}.popover .item:first-child{border-top:0}.popover .item:last-child{border-bottom:0}.popover.popover-bottom{margin-top:-12px}.popover,.popover .bar-header{border-radius:2px}.popover .scroll-content{z-index:1;margin:2px 0}.popover .bar-header{border-bottom-right-radius:0;border-bottom-left-radius:0}.popover .has-header{border-top-right-radius:0;border-top-left-radius:0}.popover-arrow{display:none}.platform-ios .popover{box-shadow:0 0 40px rgba(0,0,0,.08);border-radius:10px}.platform-ios .popover .bar-header{-webkit-border-top-right-radius:10px;border-top-right-radius:10px;-webkit-border-top-left-radius:10px;border-top-left-radius:10px}.platform-ios .popover .scroll-content{margin:8px 0;border-radius:10px}.platform-ios .popover .scroll-content.has-header{margin-top:0}.platform-ios .popover-arrow{position:absolute;display:block;top:-17px;width:30px;height:19px;overflow:hidden}.platform-ios .popover-arrow:after{position:absolute;top:12px;left:5px;width:20px;height:20px;background-color:#fff;border-radius:3px;content:'';-webkit-transform:rotate(-45deg);transform:rotate(-45deg)}.platform-ios .popover-bottom .popover-arrow{top:auto;bottom:-10px}.platform-ios .popover-bottom .popover-arrow:after{top:-6px}.platform-android .popover{margin-top:-32px;background-color:#fafafa;box-shadow:0 2px 6px rgba(0,0,0,.35)}.platform-android .popover .item{border-color:#fafafa;background-color:#fafafa;color:#4d4d4d}.platform-android .popover.popover-bottom{margin-top:32px}.platform-android .popover-backdrop,.platform-android .popover-backdrop.active{background-color:transparent}.popover-open{pointer-events:none}.popover-open .popover,.popover-open .popover-backdrop{pointer-events:auto}.popover-open.loading-active .popover,.popover-open.loading-active .popover-backdrop{pointer-events:none}@media (min-width:680px){.popover{width:360px;margin-left:-180px}}.popup-container{position:absolute;top:0;left:0;bottom:0;right:0;background:0 0;display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-pack:center;-ms-flex-pack:center;-webkit-justify-content:center;-moz-justify-content:center;justify-content:center;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;z-index:12;visibility:hidden}.popup-container.popup-showing{visibility:visible}.popup-container.popup-hidden .popup{-webkit-animation-name:scaleOut;animation-name:scaleOut;-webkit-animation-duration:.1s;animation-duration:.1s;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out;-webkit-animation-fill-mode:both;animation-fill-mode:both}.popup-container.active .popup{-webkit-animation-name:superScaleIn;animation-name:superScaleIn;-webkit-animation-duration:.2s;animation-duration:.2s;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out;-webkit-animation-fill-mode:both;animation-fill-mode:both}.popup-container .popup{width:250px;max-width:100%;max-height:90%;border-radius:0;background-color:rgba(255,255,255,.9);display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-direction:normal;-webkit-box-orient:vertical;-webkit-flex-direction:column;-moz-flex-direction:column;-ms-flex-direction:column;flex-direction:column}.popup-container input,.popup-container textarea{width:100%}.popup-head{padding:15px 10px;border-bottom:1px solid #eee;text-align:center}.popup-title{margin:0;padding:0;font-size:15px}.popup-sub-title{margin:5px 0 0 0;padding:0;font-weight:400;font-size:11px}.popup-body{padding:10px;overflow:auto}.popup-buttons{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-direction:normal;-webkit-box-orient:horizontal;-webkit-flex-direction:row;-moz-flex-direction:row;-ms-flex-direction:row;flex-direction:row;padding:10px;min-height:65px}.popup-buttons .button{-webkit-box-flex:1;-webkit-flex:1;-moz-box-flex:1;-moz-flex:1;-ms-flex:1;flex:1;display:block;min-height:45px;border-radius:2px;line-height:20px;margin-right:5px}.popup-buttons .button:last-child{margin-right:0}.popup-open,.popup-open.modal-open .modal{pointer-events:none}.popup-open .popup,.popup-open .popup-backdrop{pointer-events:auto}.loading-container{position:absolute;left:0;top:0;right:0;bottom:0;z-index:13;display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-pack:center;-ms-flex-pack:center;-webkit-justify-content:center;-moz-justify-content:center;justify-content:center;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;-webkit-transition:.2s opacity linear;transition:.2s opacity linear;visibility:hidden;opacity:0}.loading-container:not(.visible) .icon,.loading-container:not(.visible) .spinner{display:none}.loading-container.visible{visibility:visible}.loading-container.active{opacity:1}.loading-container .loading{padding:20px;border-radius:5px;background-color:rgba(0,0,0,.7);color:#fff;text-align:center;text-overflow:ellipsis;font-size:15px}.loading-container .loading h1,.loading-container .loading h2,.loading-container .loading h3,.loading-container .loading h4,.loading-container .loading h5,.loading-container .loading h6{color:#fff}.item{border-color:#ddd;background-color:#fff;color:#444;position:relative;z-index:2;display:block;margin:-1px;padding:16px;border-width:1px;border-style:solid;font-size:16px}.item h2{margin:0 0 2px 0;font-size:16px;font-weight:400}.item h3{margin:0 0 4px 0;font-size:14px}.item h4{margin:0 0 4px 0;font-size:12px}.item h5,.item h6{margin:0 0 3px 0;font-size:10px}.item p{color:#666;font-size:14px;margin-bottom:2px}.item h1:last-child,.item h2:last-child,.item h3:last-child,.item h4:last-child,.item h5:last-child,.item h6:last-child,.item p:last-child{margin-bottom:0}.item .badge{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;position:absolute;top:16px;right:32px}.item.item-button-right .badge{right:67px}.item.item-divider .badge{top:8px}.item .badge+.badge{margin-right:5px}.item.item-light{border-color:#ddd;background-color:#fff;color:#444}.item.item-stable{border-color:#b2b2b2;background-color:#f8f8f8;color:#444}.item.item-positive{border-color:#0c60ee;background-color:#387ef5;color:#fff}.item.item-calm{border-color:#0a9dc7;background-color:#11c1f3;color:#fff}.item.item-assertive{border-color:#e42112;background-color:#ef473a;color:#fff}.item.item-balanced{border-color:#28a54c;background-color:#33cd5f;color:#fff}.item.item-energized{border-color:#e6b500;background-color:#ffc900;color:#fff}.item.item-royal{border-color:#6b46e5;background-color:#886aea;color:#fff}.item.item-dark{border-color:#111;background-color:#444;color:#fff}.item[ng-click]:hover{cursor:pointer}.item-borderless,.list-borderless .item{border-width:0}.item .item-content.activated,.item .item-content.activated.item-complex>.item-content,.item .item-content.active,.item .item-content.active.item-complex>.item-content,.item-complex.activated .item-content,.item-complex.activated .item-content.item-complex>.item-content,.item-complex.active .item-content,.item-complex.active .item-content.item-complex>.item-content,.item.activated,.item.activated.item-complex>.item-content,.item.active,.item.active.item-complex>.item-content{border-color:#ccc;background-color:#D9D9D9}.item .item-content.activated.item-light,.item .item-content.activated.item-light.item-complex>.item-content,.item .item-content.active.item-light,.item .item-content.active.item-light.item-complex>.item-content,.item-complex.activated .item-content.item-light,.item-complex.activated .item-content.item-light.item-complex>.item-content,.item-complex.active .item-content.item-light,.item-complex.active .item-content.item-light.item-complex>.item-content,.item.activated.item-light,.item.activated.item-light.item-complex>.item-content,.item.active.item-light,.item.active.item-light.item-complex>.item-content{border-color:#ccc;background-color:#fafafa}.item .item-content.activated.item-stable,.item .item-content.activated.item-stable.item-complex>.item-content,.item .item-content.active.item-stable,.item .item-content.active.item-stable.item-complex>.item-content,.item-complex.activated .item-content.item-stable,.item-complex.activated .item-content.item-stable.item-complex>.item-content,.item-complex.active .item-content.item-stable,.item-complex.active .item-content.item-stable.item-complex>.item-content,.item.activated.item-stable,.item.activated.item-stable.item-complex>.item-content,.item.active.item-stable,.item.active.item-stable.item-complex>.item-content{border-color:#a2a2a2;background-color:#e5e5e5}.item .item-content.activated.item-positive,.item .item-content.activated.item-positive.item-complex>.item-content,.item .item-content.active.item-positive,.item .item-content.active.item-positive.item-complex>.item-content,.item-complex.activated .item-content.item-positive,.item-complex.activated .item-content.item-positive.item-complex>.item-content,.item-complex.active .item-content.item-positive,.item-complex.active .item-content.item-positive.item-complex>.item-content,.item.activated.item-positive,.item.activated.item-positive.item-complex>.item-content,.item.active.item-positive,.item.active.item-positive.item-complex>.item-content{border-color:#0c60ee;background-color:#0c60ee}.item .item-content.activated.item-calm,.item .item-content.activated.item-calm.item-complex>.item-content,.item .item-content.active.item-calm,.item .item-content.active.item-calm.item-complex>.item-content,.item-complex.activated .item-content.item-calm,.item-complex.activated .item-content.item-calm.item-complex>.item-content,.item-complex.active .item-content.item-calm,.item-complex.active .item-content.item-calm.item-complex>.item-content,.item.activated.item-calm,.item.activated.item-calm.item-complex>.item-content,.item.active.item-calm,.item.active.item-calm.item-complex>.item-content{border-color:#0a9dc7;background-color:#0a9dc7}.item .item-content.activated.item-assertive,.item .item-content.activated.item-assertive.item-complex>.item-content,.item .item-content.active.item-assertive,.item .item-content.active.item-assertive.item-complex>.item-content,.item-complex.activated .item-content.item-assertive,.item-complex.activated .item-content.item-assertive.item-complex>.item-content,.item-complex.active .item-content.item-assertive,.item-complex.active .item-content.item-assertive.item-complex>.item-content,.item.activated.item-assertive,.item.activated.item-assertive.item-complex>.item-content,.item.active.item-assertive,.item.active.item-assertive.item-complex>.item-content{border-color:#e42112;background-color:#e42112}.item .item-content.activated.item-balanced,.item .item-content.activated.item-balanced.item-complex>.item-content,.item .item-content.active.item-balanced,.item .item-content.active.item-balanced.item-complex>.item-content,.item-complex.activated .item-content.item-balanced,.item-complex.activated .item-content.item-balanced.item-complex>.item-content,.item-complex.active .item-content.item-balanced,.item-complex.active .item-content.item-balanced.item-complex>.item-content,.item.activated.item-balanced,.item.activated.item-balanced.item-complex>.item-content,.item.active.item-balanced,.item.active.item-balanced.item-complex>.item-content{border-color:#28a54c;background-color:#28a54c}.item .item-content.activated.item-energized,.item .item-content.activated.item-energized.item-complex>.item-content,.item .item-content.active.item-energized,.item .item-content.active.item-energized.item-complex>.item-content,.item-complex.activated .item-content.item-energized,.item-complex.activated .item-content.item-energized.item-complex>.item-content,.item-complex.active .item-content.item-energized,.item-complex.active .item-content.item-energized.item-complex>.item-content,.item.activated.item-energized,.item.activated.item-energized.item-complex>.item-content,.item.active.item-energized,.item.active.item-energized.item-complex>.item-content{border-color:#e6b500;background-color:#e6b500}.item .item-content.activated.item-royal,.item .item-content.activated.item-royal.item-complex>.item-content,.item .item-content.active.item-royal,.item .item-content.active.item-royal.item-complex>.item-content,.item-complex.activated .item-content.item-royal,.item-complex.activated .item-content.item-royal.item-complex>.item-content,.item-complex.active .item-content.item-royal,.item-complex.active .item-content.item-royal.item-complex>.item-content,.item.activated.item-royal,.item.activated.item-royal.item-complex>.item-content,.item.active.item-royal,.item.active.item-royal.item-complex>.item-content{border-color:#6b46e5;background-color:#6b46e5}.item .item-content.activated.item-dark,.item .item-content.activated.item-dark.item-complex>.item-content,.item .item-content.active.item-dark,.item .item-content.active.item-dark.item-complex>.item-content,.item-complex.activated .item-content.item-dark,.item-complex.activated .item-content.item-dark.item-complex>.item-content,.item-complex.active .item-content.item-dark,.item-complex.active .item-content.item-dark.item-complex>.item-content,.item.activated.item-dark,.item.activated.item-dark.item-complex>.item-content,.item.active.item-dark,.item.active.item-dark.item-complex>.item-content{border-color:#000;background-color:#262626}.item,.item h1,.item h2,.item h3,.item h4,.item h5,.item h6,.item p,.item-content,.item-content h1,.item-content h2,.item-content h3,.item-content h4,.item-content h5,.item-content h6,.item-content p{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}a.item{color:inherit;text-decoration:none}a.item:focus,a.item:hover{text-decoration:none}.item-complex,a.item.item-complex,button.item.item-complex{padding:0}.item-complex .item-content,.item-radio .item-content{position:relative;z-index:2;padding:16px 49px 16px 16px;border:none;background-color:#fff}a.item-content{display:block;color:inherit;text-decoration:none}.item-body h1,.item-body h2,.item-body h3,.item-body h4,.item-body h5,.item-body h6,.item-body p,.item-complex.item-text-wrap,.item-complex.item-text-wrap .item-content,.item-complex.item-text-wrap h1,.item-complex.item-text-wrap h2,.item-complex.item-text-wrap h3,.item-complex.item-text-wrap h4,.item-complex.item-text-wrap h5,.item-complex.item-text-wrap h6,.item-complex.item-text-wrap p,.item-text-wrap,.item-text-wrap .item,.item-text-wrap .item-content,.item-text-wrap h1,.item-text-wrap h2,.item-text-wrap h3,.item-text-wrap h4,.item-text-wrap h5,.item-text-wrap h6,.item-text-wrap p{overflow:visible;white-space:normal}.item-complex.item-light>.item-content{border-color:#ddd;background-color:#fff;color:#444}.item-complex.item-light>.item-content.active,.item-complex.item-light>.item-content.active.item-complex>.item-content,.item-complex.item-light>.item-content:active,.item-complex.item-light>.item-content:active.item-complex>.item-content{border-color:#ccc;background-color:#fafafa}.item-complex.item-stable>.item-content{border-color:#b2b2b2;background-color:#f8f8f8;color:#444}.item-complex.item-stable>.item-content.active,.item-complex.item-stable>.item-content.active.item-complex>.item-content,.item-complex.item-stable>.item-content:active,.item-complex.item-stable>.item-content:active.item-complex>.item-content{border-color:#a2a2a2;background-color:#e5e5e5}.item-complex.item-positive>.item-content{border-color:#0c60ee;background-color:#387ef5;color:#fff}.item-complex.item-positive>.item-content.active,.item-complex.item-positive>.item-content.active.item-complex>.item-content,.item-complex.item-positive>.item-content:active,.item-complex.item-positive>.item-content:active.item-complex>.item-content{border-color:#0c60ee;background-color:#0c60ee}.item-complex.item-calm>.item-content{border-color:#0a9dc7;background-color:#11c1f3;color:#fff}.item-complex.item-calm>.item-content.active,.item-complex.item-calm>.item-content.active.item-complex>.item-content,.item-complex.item-calm>.item-content:active,.item-complex.item-calm>.item-content:active.item-complex>.item-content{border-color:#0a9dc7;background-color:#0a9dc7}.item-complex.item-assertive>.item-content{border-color:#e42112;background-color:#ef473a;color:#fff}.item-complex.item-assertive>.item-content.active,.item-complex.item-assertive>.item-content.active.item-complex>.item-content,.item-complex.item-assertive>.item-content:active,.item-complex.item-assertive>.item-content:active.item-complex>.item-content{border-color:#e42112;background-color:#e42112}.item-complex.item-balanced>.item-content{border-color:#28a54c;background-color:#33cd5f;color:#fff}.item-complex.item-balanced>.item-content.active,.item-complex.item-balanced>.item-content.active.item-complex>.item-content,.item-complex.item-balanced>.item-content:active,.item-complex.item-balanced>.item-content:active.item-complex>.item-content{border-color:#28a54c;background-color:#28a54c}.item-complex.item-energized>.item-content{border-color:#e6b500;background-color:#ffc900;color:#fff}.item-complex.item-energized>.item-content.active,.item-complex.item-energized>.item-content.active.item-complex>.item-content,.item-complex.item-energized>.item-content:active,.item-complex.item-energized>.item-content:active.item-complex>.item-content{border-color:#e6b500;background-color:#e6b500}.item-complex.item-royal>.item-content{border-color:#6b46e5;background-color:#886aea;color:#fff}.item-complex.item-royal>.item-content.active,.item-complex.item-royal>.item-content.active.item-complex>.item-content,.item-complex.item-royal>.item-content:active,.item-complex.item-royal>.item-content:active.item-complex>.item-content{border-color:#6b46e5;background-color:#6b46e5}.item-complex.item-dark>.item-content{border-color:#111;background-color:#444;color:#fff}.item-complex.item-dark>.item-content.active,.item-complex.item-dark>.item-content.active.item-complex>.item-content,.item-complex.item-dark>.item-content:active,.item-complex.item-dark>.item-content:active.item-complex>.item-content{border-color:#000;background-color:#262626}.item-icon-left .icon,.item-icon-right .icon{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;position:absolute;top:0;height:100%;font-size:32px}.item-icon-left .icon:before,.item-icon-right .icon:before{display:block;width:32px;text-align:center}.item .fill-icon{min-width:30px;min-height:30px;font-size:28px}.item-icon-left{padding-left:54px}.item-icon-left .icon{left:11px}.item-complex.item-icon-left{padding-left:0}.item-complex.item-icon-left .item-content{padding-left:54px}.item-icon-right{padding-right:54px}.item-icon-right .icon{right:11px}.item-complex.item-icon-right{padding-right:0}.item-complex.item-icon-right .item-content{padding-right:54px}.item-icon-left.item-icon-right .icon:first-child{right:auto}.item-icon-left .item-delete .icon,.item-icon-left.item-icon-right .icon:last-child{left:auto}.item-icon-left .icon-accessory,.item-icon-right .icon-accessory{color:#ccc;font-size:16px}.item-icon-left .icon-accessory{left:3px}.item-icon-right .icon-accessory{right:3px}.item-button-left{padding-left:72px}.item-button-left .item-content>.button,.item-button-left>.button{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;position:absolute;top:8px;left:11px;min-width:34px;min-height:34px;font-size:18px;line-height:32px}.item-button-left .item-content>.button .icon:before,.item-button-left>.button .icon:before{position:relative;left:auto;width:auto;line-height:31px}.item-button-left .item-content>.button>.button,.item-button-left>.button>.button{margin:0 2px;min-height:34px;font-size:18px;line-height:32px}.item-button-right,a.item.item-button-right,button.item.item-button-right{padding-right:80px}.item-button-right .item-content>.button,.item-button-right .item-content>.buttons,.item-button-right>.button,.item-button-right>.buttons{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;position:absolute;top:8px;right:16px;min-width:34px;min-height:34px;font-size:18px;line-height:32px}.item-button-right .item-content>.button .icon:before,.item-button-right .item-content>.buttons .icon:before,.item-button-right>.button .icon:before,.item-button-right>.buttons .icon:before{position:relative;left:auto;width:auto;line-height:31px}.item-button-right .item-content>.button>.button,.item-button-right .item-content>.buttons>.button,.item-button-right>.button>.button,.item-button-right>.buttons>.button{margin:0 2px;min-width:34px;min-height:34px;font-size:18px;line-height:32px}.item-button-left.item-button-right .button:first-child{right:auto}.item-button-left.item-button-right .button:last-child{left:auto}.item-avatar,.item-avatar .item-content,.item-avatar-left,.item-avatar-left .item-content{padding-left:72px;min-height:72px}.item-avatar .item-content .item-image,.item-avatar .item-content>img:first-child,.item-avatar .item-image,.item-avatar-left .item-content .item-image,.item-avatar-left .item-content>img:first-child,.item-avatar-left .item-image,.item-avatar-left>img:first-child,.item-avatar>img:first-child{position:absolute;top:16px;left:16px;max-width:40px;max-height:40px;width:100%;height:100%;border-radius:50%}.item-avatar-right,.item-avatar-right .item-content{padding-right:72px;min-height:72px}.item-avatar-right .item-content .item-image,.item-avatar-right .item-content>img:first-child,.item-avatar-right .item-image,.item-avatar-right>img:first-child{position:absolute;top:16px;right:16px;max-width:40px;max-height:40px;width:100%;height:100%;border-radius:50%}.item-thumbnail-left,.item-thumbnail-left .item-content{padding-top:8px;padding-left:106px;min-height:100px}.item-thumbnail-left .item-content .item-image,.item-thumbnail-left .item-content>img:first-child,.item-thumbnail-left .item-image,.item-thumbnail-left>img:first-child{position:absolute;top:10px;left:10px;max-width:80px;max-height:80px;width:100%;height:100%}.item-avatar-left.item-complex,.item-avatar.item-complex,.item-thumbnail-left.item-complex{padding-top:0;padding-left:0}.item-thumbnail-right,.item-thumbnail-right .item-content{padding-top:8px;padding-right:106px;min-height:100px}.item-thumbnail-right .item-content .item-image,.item-thumbnail-right .item-content>img:first-child,.item-thumbnail-right .item-image,.item-thumbnail-right>img:first-child{position:absolute;top:10px;right:10px;max-width:80px;max-height:80px;width:100%;height:100%}.item-avatar-right.item-complex,.item-thumbnail-right.item-complex{padding-top:0;padding-right:0}.item-image{padding:0;text-align:center}.item-image .list-img,.item-image img:first-child{width:100%;vertical-align:middle}.item-body{overflow:auto;padding:16px;text-overflow:inherit;white-space:normal}.item-body h1,.item-body h2,.item-body h3,.item-body h4,.item-body h5,.item-body h6,.item-body p{margin-top:16px;margin-bottom:16px}.item-divider{padding-top:8px;padding-bottom:8px;min-height:30px;background-color:#f5f5f5;color:#222;font-weight:500}.item-divider-ios,.platform-ios .item-divider-platform{padding-top:26px;text-transform:uppercase;font-weight:300;font-size:13px;background-color:#efeff4;color:#555}.item-divider-android,.platform-android .item-divider-platform{font-weight:300;font-size:13px}.item-note{float:right;color:#aaa;font-size:14px}.item-left-editable .item-content,.item-right-editable .item-content{-webkit-transition-duration:250ms;transition-duration:250ms;-webkit-transition-timing-function:ease-in-out;transition-timing-function:ease-in-out;-webkit-transition-property:-webkit-transform;-moz-transition-property:-moz-transform;transition-property:transform}.item-left-editing.item-left-editable .item-content,.list-left-editing .item-left-editable .item-content{-webkit-transform:translate3d(50px,0,0);transform:translate3d(50px,0,0)}.item-remove-animate.ng-leave{-webkit-transition-duration:300ms;transition-duration:300ms}.item-remove-animate.ng-leave .item-content,.item-remove-animate.ng-leave:last-of-type{-webkit-transition-duration:300ms;transition-duration:300ms;-webkit-transition-timing-function:ease-in;transition-timing-function:ease-in;-webkit-transition-property:all;transition-property:all}.item-remove-animate.ng-leave.ng-leave-active .item-content{opacity:0;-webkit-transform:translate3d(-100%,0,0)!important;transform:translate3d(-100%,0,0)!important}.item-remove-animate.ng-leave.ng-leave-active:last-of-type{opacity:0}.item-remove-animate.ng-leave.ng-leave-active~ion-item:not(.ng-leave){-webkit-transform:translate3d(0,-webkit-calc(-100% + 1px),0);transform:translate3d(0,calc(-100% + 1px),0);-webkit-transition-duration:300ms;transition-duration:300ms;-webkit-transition-timing-function:cubic-bezier(.25,.81,.24,1);transition-timing-function:cubic-bezier(.25,.81,.24,1);-webkit-transition-property:all;transition-property:all}.item-left-edit{-webkit-transition:all ease-in-out 125ms;transition:all ease-in-out 125ms;position:absolute;top:0;left:0;z-index:0;width:50px;height:100%;line-height:100%;display:none;opacity:0;-webkit-transform:translate3d(-21px,0,0);transform:translate3d(-21px,0,0)}.item-left-edit .button{height:100%}.item-left-edit .button.icon{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;position:absolute;top:0;height:100%}.item-left-edit.visible{display:block}.item-left-edit.visible.active{opacity:1;-webkit-transform:translate3d(8px,0,0);transform:translate3d(8px,0,0)}.list-left-editing .item-left-edit{-webkit-transition-delay:125ms;transition-delay:125ms}.item-delete .button.icon{color:#ef473a;font-size:24px}.item-delete .button.icon:hover{opacity:.7}.item-right-edit{-webkit-transition:all ease-in-out 250ms;transition:all ease-in-out 250ms;position:absolute;top:0;right:0;z-index:3;width:75px;height:100%;background:inherit;padding-left:20px;display:block;opacity:0;-webkit-transform:translate3d(75px,0,0);transform:translate3d(75px,0,0)}.item-right-edit .button{min-width:50px;height:100%}.item-right-edit .button.icon{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;position:absolute;top:0;height:100%;font-size:32px}.item-right-edit.visible{display:block}.item-right-edit.visible.active{opacity:1;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.item-reorder .button.icon{color:#444;font-size:32px}.item-reordering{position:absolute;left:0;top:0;z-index:9;width:100%;box-shadow:0 0 10px 0 #aaa}.item-reordering .item-reorder{z-index:9}.item-placeholder{opacity:.7}.item-options{position:absolute;top:0;right:0;z-index:1;height:100%}.item-options .button{height:100%;border:none;border-radius:0;display:-webkit-inline-box;display:-webkit-inline-flex;display:-moz-inline-flex;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center}.item-options .button:before{margin:0 auto}.list{position:relative;padding-top:1px;padding-bottom:1px;padding-left:0;margin-bottom:20px}.list:last-child{margin-bottom:0}.list:last-child.card{margin-bottom:40px}.list-header{margin-top:20px;padding:5px 15px;background-color:transparent;color:#222;font-weight:700}.card.list .list-item{padding-right:1px;padding-left:1px}.card,.list-inset{overflow:hidden;margin:20px 10px;border-radius:2px;background-color:#fff}.card{padding-top:1px;padding-bottom:1px;box-shadow:0 1px 3px rgba(0,0,0,.3)}.card .item{border-left:0;border-right:0}.card .item:first-child{border-top:0}.card .item:last-child{border-bottom:0}.padding .card,.padding .list-inset{margin-left:0;margin-right:0}.card .item:first-child,.card .item:first-child .item-content,.list-inset .item:first-child,.list-inset .item:first-child .item-content,.padding>.list .item:first-child,.padding>.list .item:first-child .item-content{border-top-left-radius:2px;border-top-right-radius:2px}.card .item:last-child,.card .item:last-child .item-content,.list-inset .item:last-child,.list-inset .item:last-child .item-content,.padding>.list .item:last-child,.padding>.list .item:last-child .item-content{border-bottom-right-radius:2px;border-bottom-left-radius:2px}.card .item:last-child,.list-inset .item:last-child{margin-bottom:-1px}.card .item,.list-inset .item,.padding-horizontal>.list .item,.padding>.list .item{margin-right:0;margin-left:0}.card .item.item-input input,.list-inset .item.item-input input,.padding-horizontal>.list .item.item-input input,.padding>.list .item.item-input input{padding-right:44px}.padding-left>.list .item{margin-left:0}.padding-right>.list .item{margin-right:0}.badge{background-color:transparent;color:#AAA;z-index:1;display:inline-block;padding:3px 8px;min-width:10px;border-radius:10px;vertical-align:baseline;text-align:center;white-space:nowrap;font-weight:700;font-size:14px;line-height:16px}.badge:empty{display:none}.badge.badge-light,.tabs .tab-item .badge.badge-light{background-color:#fff;color:#444}.badge.badge-stable,.tabs .tab-item .badge.badge-stable{background-color:#f8f8f8;color:#444}.badge.badge-positive,.tabs .tab-item .badge.badge-positive{background-color:#387ef5;color:#fff}.badge.badge-calm,.tabs .tab-item .badge.badge-calm{background-color:#11c1f3;color:#fff}.badge.badge-assertive,.tabs .tab-item .badge.badge-assertive{background-color:#ef473a;color:#fff}.badge.badge-balanced,.tabs .tab-item .badge.badge-balanced{background-color:#33cd5f;color:#fff}.badge.badge-energized,.tabs .tab-item .badge.badge-energized{background-color:#ffc900;color:#fff}.badge.badge-royal,.tabs .tab-item .badge.badge-royal{background-color:#886aea;color:#fff}.badge.badge-dark,.tabs .tab-item .badge.badge-dark{background-color:#444;color:#fff}.button .badge{position:relative;top:-1px}.slider{position:relative;visibility:hidden;overflow:hidden}.slider-slides{position:relative;height:100%}.slider-slide{position:relative;display:block;float:left;width:100%;height:100%;vertical-align:top}.slider-slide-image>img{width:100%}.slider-pager{position:absolute;bottom:20px;z-index:1;width:100%;height:15px;text-align:center}.slider-pager .slider-pager-page{display:inline-block;margin:0 3px;width:15px;color:#000;text-decoration:none;opacity:.3}.slider-pager .slider-pager-page.active{-webkit-transition:opacity .4s ease-in;transition:opacity .4s ease-in;opacity:1}.slider-pager-page.ng-animate,.slider-pager-page.ng-enter,.slider-pager-page.ng-leave,.slider-slide.ng-animate,.slider-slide.ng-enter,.slider-slide.ng-leave{-webkit-transition:none!important;transition:none!important}.slider-pager-page.ng-animate,.slider-slide.ng-animate{-webkit-animation:none 0s;animation:none 0s}.swiper-container{margin:0 auto;position:relative;z-index:1}.swiper-container-no-flexbox .swiper-slide{float:left}.swiper-container-vertical>.swiper-wrapper{-webkit-box-orient:vertical;-moz-box-orient:vertical;-ms-flex-direction:column;-webkit-flex-direction:column;flex-direction:column}.swiper-wrapper{z-index:1;display:-webkit-box;display:-moz-box;display:-ms-flexbox;display:-webkit-flex;display:flex;-webkit-transition-property:-webkit-transform;-moz-transition-property:-moz-transform;-o-transition-property:-o-transform;-ms-transition-property:-ms-transform;transition-property:transform;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}.swiper-container-android .swiper-slide,.swiper-wrapper{-webkit-transform:translate3d(0,0,0);-moz-transform:translate3d(0,0,0);-o-transform:translate(0,0);-ms-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.swiper-container-multirow>.swiper-wrapper{-webkit-box-lines:multiple;-moz-box-lines:multiple;-ms-flex-wrap:wrap;-webkit-flex-wrap:wrap;flex-wrap:wrap}.swiper-container-free-mode>.swiper-wrapper{-webkit-transition-timing-function:ease-out;-moz-transition-timing-function:ease-out;-ms-transition-timing-function:ease-out;-o-transition-timing-function:ease-out;transition-timing-function:ease-out;margin:0 auto}.swiper-slide{display:block;-webkit-flex-shrink:0;-ms-flex:0 0 auto;flex-shrink:0;position:relative}.swiper-container-autoheight,.swiper-container-autoheight .swiper-slide{height:auto}.swiper-container-autoheight .swiper-wrapper{-webkit-box-align:start;-ms-flex-align:start;-webkit-align-items:flex-start;align-items:flex-start;-webkit-transition-property:-webkit-transform,height;-moz-transition-property:-moz-transform;-o-transition-property:-o-transform;-ms-transition-property:-ms-transform;transition-property:transform,height}.swiper-container .swiper-notification{position:absolute;left:0;top:0;pointer-events:none;opacity:0;z-index:-1000}.swiper-wp8-horizontal{-ms-touch-action:pan-y;touch-action:pan-y}.swiper-wp8-vertical{-ms-touch-action:pan-x;touch-action:pan-x}.swiper-button-next,.swiper-button-prev{position:absolute;top:50%;width:27px;height:44px;margin-top:-22px;z-index:10;cursor:pointer;-moz-background-size:27px 44px;-webkit-background-size:27px 44px;background-size:27px 44px;background-position:center;background-repeat:no-repeat}.swiper-button-next.swiper-button-disabled,.swiper-button-prev.swiper-button-disabled{opacity:.35;cursor:auto;pointer-events:none}.swiper-button-prev,.swiper-container-rtl .swiper-button-next{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M0%2C22L22%2C0l2.1%2C2.1L4.2%2C22l19.9%2C19.9L22%2C44L0%2C22L0%2C22L0%2C22z'%20fill%3D'%23007aff'%2F%3E%3C%2Fsvg%3E");left:10px;right:auto}.swiper-button-prev.swiper-button-black,.swiper-container-rtl .swiper-button-next.swiper-button-black{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M0%2C22L22%2C0l2.1%2C2.1L4.2%2C22l19.9%2C19.9L22%2C44L0%2C22L0%2C22L0%2C22z'%20fill%3D'%23000000'%2F%3E%3C%2Fsvg%3E")}.swiper-button-prev.swiper-button-white,.swiper-container-rtl .swiper-button-next.swiper-button-white{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M0%2C22L22%2C0l2.1%2C2.1L4.2%2C22l19.9%2C19.9L22%2C44L0%2C22L0%2C22L0%2C22z'%20fill%3D'%23ffffff'%2F%3E%3C%2Fsvg%3E")}.swiper-button-next,.swiper-container-rtl .swiper-button-prev{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M27%2C22L27%2C22L5%2C44l-2.1-2.1L22.8%2C22L2.9%2C2.1L5%2C0L27%2C22L27%2C22z'%20fill%3D'%23007aff'%2F%3E%3C%2Fsvg%3E");right:10px;left:auto}.swiper-button-next.swiper-button-black,.swiper-container-rtl .swiper-button-prev.swiper-button-black{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M27%2C22L27%2C22L5%2C44l-2.1-2.1L22.8%2C22L2.9%2C2.1L5%2C0L27%2C22L27%2C22z'%20fill%3D'%23000000'%2F%3E%3C%2Fsvg%3E")}.swiper-button-next.swiper-button-white,.swiper-container-rtl .swiper-button-prev.swiper-button-white{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20viewBox%3D'0%200%2027%2044'%3E%3Cpath%20d%3D'M27%2C22L27%2C22L5%2C44l-2.1-2.1L22.8%2C22L2.9%2C2.1L5%2C0L27%2C22L27%2C22z'%20fill%3D'%23ffffff'%2F%3E%3C%2Fsvg%3E")}.swiper-pagination{position:absolute;text-align:center;-webkit-transition:300ms;-moz-transition:300ms;-o-transition:300ms;transition:300ms;-webkit-transform:translate3d(0,0,0);-ms-transform:translate3d(0,0,0);-o-transform:translate3d(0,0,0);transform:translate3d(0,0,0);z-index:10}.swiper-pagination.swiper-pagination-hidden{opacity:0}.swiper-pagination-bullet{width:8px;height:8px;display:inline-block;border-radius:100%;background:#000;opacity:.2}button.swiper-pagination-bullet{border:none;margin:0;padding:0;box-shadow:none;-moz-appearance:none;-ms-appearance:none;-webkit-appearance:none;appearance:none}.swiper-pagination-clickable .swiper-pagination-bullet{cursor:pointer}.swiper-pagination-white .swiper-pagination-bullet{background:#fff}.swiper-pagination-bullet-active{opacity:1}.swiper-pagination-white .swiper-pagination-bullet-active{background:#fff}.swiper-pagination-black .swiper-pagination-bullet-active{background:#000}.swiper-container-vertical>.swiper-pagination{right:10px;top:50%;-webkit-transform:translate3d(0,-50%,0);-moz-transform:translate3d(0,-50%,0);-o-transform:translate(0,-50%);-ms-transform:translate3d(0,-50%,0);transform:translate3d(0,-50%,0)}.swiper-container-vertical>.swiper-pagination .swiper-pagination-bullet{margin:5px 0;display:block}.swiper-container-horizontal>.swiper-pagination{bottom:10px;left:0;width:100%}.swiper-container-horizontal>.swiper-pagination .swiper-pagination-bullet{margin:0 5px}.swiper-container-3d{-webkit-perspective:1200px;-moz-perspective:1200px;-o-perspective:1200px;perspective:1200px}.swiper-container-3d .swiper-cube-shadow,.swiper-container-3d .swiper-slide,.swiper-container-3d .swiper-slide-shadow-bottom,.swiper-container-3d .swiper-slide-shadow-left,.swiper-container-3d .swiper-slide-shadow-right,.swiper-container-3d .swiper-slide-shadow-top,.swiper-container-3d .swiper-wrapper{-webkit-transform-style:preserve-3d;-moz-transform-style:preserve-3d;-ms-transform-style:preserve-3d;transform-style:preserve-3d}.swiper-container-3d .swiper-slide-shadow-bottom,.swiper-container-3d .swiper-slide-shadow-left,.swiper-container-3d .swiper-slide-shadow-right,.swiper-container-3d .swiper-slide-shadow-top{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none;z-index:10}.swiper-container-3d .swiper-slide-shadow-left{background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.5)),to(transparent));background-image:-webkit-linear-gradient(right,rgba(0,0,0,.5),transparent);background-image:-moz-linear-gradient(right,rgba(0,0,0,.5),transparent);background-image:-o-linear-gradient(right,rgba(0,0,0,.5),transparent);background-image:linear-gradient(to left,rgba(0,0,0,.5),transparent)}.swiper-container-3d .swiper-slide-shadow-right{background-image:-webkit-gradient(linear,right top,left top,from(rgba(0,0,0,.5)),to(transparent));background-image:-webkit-linear-gradient(left,rgba(0,0,0,.5),transparent);background-image:-moz-linear-gradient(left,rgba(0,0,0,.5),transparent);background-image:-o-linear-gradient(left,rgba(0,0,0,.5),transparent);background-image:linear-gradient(to right,rgba(0,0,0,.5),transparent)}.swiper-container-3d .swiper-slide-shadow-top{background-image:-webkit-gradient(linear,left top,left bottom,from(rgba(0,0,0,.5)),to(transparent));background-image:-webkit-linear-gradient(bottom,rgba(0,0,0,.5),transparent);background-image:-moz-linear-gradient(bottom,rgba(0,0,0,.5),transparent);background-image:-o-linear-gradient(bottom,rgba(0,0,0,.5),transparent);background-image:linear-gradient(to top,rgba(0,0,0,.5),transparent)}.swiper-container-3d .swiper-slide-shadow-bottom{background-image:-webkit-gradient(linear,left bottom,left top,from(rgba(0,0,0,.5)),to(transparent));background-image:-webkit-linear-gradient(top,rgba(0,0,0,.5),transparent);background-image:-moz-linear-gradient(top,rgba(0,0,0,.5),transparent);background-image:-o-linear-gradient(top,rgba(0,0,0,.5),transparent);background-image:linear-gradient(to bottom,rgba(0,0,0,.5),transparent)}.swiper-container-coverflow .swiper-wrapper{-ms-perspective:1200px}.swiper-container-fade.swiper-container-free-mode .swiper-slide{-webkit-transition-timing-function:ease-out;-moz-transition-timing-function:ease-out;-ms-transition-timing-function:ease-out;-o-transition-timing-function:ease-out;transition-timing-function:ease-out}.swiper-container-fade .swiper-slide,.swiper-container-fade .swiper-slide .swiper-slide{pointer-events:none}.swiper-container-fade .swiper-slide-active,.swiper-container-fade .swiper-slide-active .swiper-slide-active{pointer-events:auto}.swiper-container-cube{overflow:visible}.swiper-container-cube .swiper-slide{pointer-events:none;visibility:hidden;-webkit-transform-origin:0 0;-moz-transform-origin:0 0;-ms-transform-origin:0 0;transform-origin:0 0;-webkit-backface-visibility:hidden;-moz-backface-visibility:hidden;-ms-backface-visibility:hidden;backface-visibility:hidden;width:100%;height:100%;z-index:1}.swiper-container-cube.swiper-container-rtl .swiper-slide{-webkit-transform-origin:100% 0;-moz-transform-origin:100% 0;-ms-transform-origin:100% 0;transform-origin:100% 0}.swiper-container-cube .swiper-slide-active,.swiper-container-cube .swiper-slide-next,.swiper-container-cube .swiper-slide-next+.swiper-slide,.swiper-container-cube .swiper-slide-prev{pointer-events:auto;visibility:visible}.swiper-container-cube .swiper-slide-shadow-bottom,.swiper-container-cube .swiper-slide-shadow-left,.swiper-container-cube .swiper-slide-shadow-right,.swiper-container-cube .swiper-slide-shadow-top{z-index:0;-webkit-backface-visibility:hidden;-moz-backface-visibility:hidden;-ms-backface-visibility:hidden;backface-visibility:hidden}.swiper-container-cube .swiper-cube-shadow{position:absolute;left:0;bottom:0;width:100%;height:100%;background:#000;opacity:.6;-webkit-filter:blur(50px);filter:blur(50px);z-index:0}.swiper-scrollbar{border-radius:10px;position:relative;-ms-touch-action:none;background:rgba(0,0,0,.1)}.swiper-container-horizontal>.swiper-scrollbar{position:absolute;left:1%;bottom:3px;z-index:50;height:5px;width:98%}.swiper-container-vertical>.swiper-scrollbar{position:absolute;right:3px;top:1%;z-index:50;width:5px;height:98%}.swiper-scrollbar-drag{height:100%;width:100%;position:relative;background:rgba(0,0,0,.5);border-radius:10px;left:0;top:0}.swiper-scrollbar-cursor-drag{cursor:move}.swiper-lazy-preloader{width:42px;height:42px;position:absolute;left:50%;top:50%;margin-left:-21px;margin-top:-21px;z-index:10;-webkit-transform-origin:50%;-moz-transform-origin:50%;transform-origin:50%;-webkit-animation:swiper-preloader-spin 1s steps(12,end) infinite;-moz-animation:swiper-preloader-spin 1s steps(12,end) infinite;animation:swiper-preloader-spin 1s steps(12,end) infinite}.swiper-lazy-preloader:after{display:block;content:"";width:100%;height:100%;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg%20viewBox%3D'0%200%20120%20120'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20xmlns%3Axlink%3D'http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink'%3E%3Cdefs%3E%3Cline%20id%3D'l'%20x1%3D'60'%20x2%3D'60'%20y1%3D'7'%20y2%3D'27'%20stroke%3D'%236c6c6c'%20stroke-width%3D'11'%20stroke-linecap%3D'round'%2F%3E%3C%2Fdefs%3E%3Cg%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(30%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(60%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(90%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(120%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(150%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.37'%20transform%3D'rotate(180%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.46'%20transform%3D'rotate(210%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.56'%20transform%3D'rotate(240%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.66'%20transform%3D'rotate(270%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.75'%20transform%3D'rotate(300%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.85'%20transform%3D'rotate(330%2060%2C60)'%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");background-position:50%;-webkit-background-size:100%;background-size:100%;background-repeat:no-repeat}.swiper-lazy-preloader-white:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg%20viewBox%3D'0%200%20120%20120'%20xmlns%3D'http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg'%20xmlns%3Axlink%3D'http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink'%3E%3Cdefs%3E%3Cline%20id%3D'l'%20x1%3D'60'%20x2%3D'60'%20y1%3D'7'%20y2%3D'27'%20stroke%3D'%23fff'%20stroke-width%3D'11'%20stroke-linecap%3D'round'%2F%3E%3C%2Fdefs%3E%3Cg%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(30%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(60%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(90%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(120%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.27'%20transform%3D'rotate(150%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.37'%20transform%3D'rotate(180%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.46'%20transform%3D'rotate(210%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.56'%20transform%3D'rotate(240%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.66'%20transform%3D'rotate(270%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.75'%20transform%3D'rotate(300%2060%2C60)'%2F%3E%3Cuse%20xlink%3Ahref%3D'%23l'%20opacity%3D'.85'%20transform%3D'rotate(330%2060%2C60)'%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")}@-webkit-keyframes swiper-preloader-spin{100%{-webkit-transform:rotate(360deg)}}@keyframes swiper-preloader-spin{100%{transform:rotate(360deg)}}ion-slides{width:100%;height:100%;display:block}.slide-zoom{display:block;width:100%;text-align:center}.swiper-container{width:100%;height:100%;padding:0;overflow:hidden}.swiper-wrapper{position:absolute;left:0;top:0;width:100%;height:100%;padding:0}.swiper-slide{width:100%;height:100%;box-sizing:border-box}.swiper-slide img{width:auto;height:auto;max-width:100%;max-height:100%}.scroll-refresher{position:absolute;top:-60px;right:0;left:0;overflow:hidden;margin:auto;height:60px}.scroll-refresher .ionic-refresher-content{position:absolute;bottom:15px;left:0;width:100%;color:#666;text-align:center;font-size:30px}.scroll-refresher .ionic-refresher-content .text-pulling,.scroll-refresher .ionic-refresher-content .text-refreshing{font-size:16px;line-height:16px}.scroll-refresher .ionic-refresher-content.ionic-refresher-with-text{bottom:10px}.scroll-refresher .icon-pulling,.scroll-refresher .icon-refreshing{width:100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-transform-style:preserve-3d;transform-style:preserve-3d}.scroll-refresher .icon-pulling{-webkit-animation-name:refresh-spin-back;animation-name:refresh-spin-back;-webkit-animation-duration:200ms;animation-duration:200ms;-webkit-animation-timing-function:linear;animation-timing-function:linear;-webkit-animation-fill-mode:none;animation-fill-mode:none;-webkit-transform:translate3d(0,0,0) rotate(0deg);transform:translate3d(0,0,0) rotate(0deg)}.scroll-refresher .icon-refreshing,.scroll-refresher .text-refreshing{display:none}.scroll-refresher .icon-refreshing{-webkit-animation-duration:1.5s;animation-duration:1.5s}.scroll-refresher.active .icon-pulling:not(.pulling-rotation-disabled){-webkit-animation-name:refresh-spin;animation-name:refresh-spin;-webkit-transform:translate3d(0,0,0) rotate(-180deg);transform:translate3d(0,0,0) rotate(-180deg)}.scroll-refresher.active.refreshing{-webkit-transition:transform .2s;transition:transform .2s;-webkit-transform:scale(1,1);transform:scale(1,1)}.scroll-refresher.active.refreshing .icon-pulling,.scroll-refresher.active.refreshing .text-pulling{display:none}.scroll-refresher.active.refreshing .icon-refreshing,.scroll-refresher.active.refreshing .text-refreshing{display:block}.scroll-refresher.active.refreshing.refreshing-tail{-webkit-transform:scale(0,0);transform:scale(0,0)}.overflow-scroll>.scroll{-webkit-overflow-scrolling:touch;width:100%}.overflow-scroll>.scroll.overscroll{position:fixed;right:0;left:0}.overflow-scroll.padding>.scroll.overscroll{padding:10px}@-webkit-keyframes refresh-spin{0%{-webkit-transform:translate3d(0,0,0) rotate(0)}100%{-webkit-transform:translate3d(0,0,0) rotate(180deg)}}@keyframes refresh-spin{0%{transform:translate3d(0,0,0) rotate(0)}100%{transform:translate3d(0,0,0) rotate(180deg)}}@-webkit-keyframes refresh-spin-back{0%{-webkit-transform:translate3d(0,0,0) rotate(180deg)}100%{-webkit-transform:translate3d(0,0,0) rotate(0)}}@keyframes refresh-spin-back{0%{transform:translate3d(0,0,0) rotate(180deg)}100%{transform:translate3d(0,0,0) rotate(0)}}.spinner{stroke:#444;fill:#444}.spinner svg{width:28px;height:28px}.spinner.spinner-light{stroke:#fff;fill:#fff}.spinner.spinner-stable{stroke:#f8f8f8;fill:#f8f8f8}.spinner.spinner-positive{stroke:#387ef5;fill:#387ef5}.spinner.spinner-calm{stroke:#11c1f3;fill:#11c1f3}.spinner.spinner-balanced{stroke:#33cd5f;fill:#33cd5f}.spinner.spinner-assertive{stroke:#ef473a;fill:#ef473a}.spinner.spinner-energized{stroke:#ffc900;fill:#ffc900}.spinner.spinner-royal{stroke:#886aea;fill:#886aea}.spinner.spinner-dark{stroke:#444;fill:#444}.spinner-android{stroke:#4b8bf4}.spinner-ios,.spinner-ios-small{stroke:#69717d}.spinner-spiral .stop1{stop-color:#fff;stop-opacity:0}.spinner-spiral.spinner-light .stop1{stop-color:#444}.spinner-spiral.spinner-light .stop2{stop-color:#fff}.spinner-spiral.spinner-stable .stop2{stop-color:#f8f8f8}.spinner-spiral.spinner-positive .stop2{stop-color:#387ef5}.spinner-spiral.spinner-calm .stop2{stop-color:#11c1f3}.spinner-spiral.spinner-balanced .stop2{stop-color:#33cd5f}.spinner-spiral.spinner-assertive .stop2{stop-color:#ef473a}.spinner-spiral.spinner-energized .stop2{stop-color:#ffc900}.spinner-spiral.spinner-royal .stop2{stop-color:#886aea}.spinner-spiral.spinner-dark .stop2{stop-color:#444}form{margin:0 0 1.42857}legend{display:block;margin-bottom:1.42857;padding:0;width:100%;border:1px solid #ddd;color:#444;font-size:21px;line-height:2.85714}legend small{color:#f8f8f8;font-size:1.07143}button,input,label,select,textarea{font-weight:400;font-size:14px;line-height:1.42857}button,input,select,textarea{font-family:"-apple-system","Helvetica Neue",Roboto,"Segoe UI",sans-serif}.item-input{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;position:relative;overflow:hidden;padding:6px 0 5px 16px}.item-input input{-webkit-border-radius:0;border-radius:0;-webkit-box-flex:1;-webkit-flex:1 220px;-moz-box-flex:1;-moz-flex:1 220px;-ms-flex:1 220px;flex:1 220px;-webkit-appearance:none;-moz-appearance:none;appearance:none;margin:0;padding-right:24px;background-color:transparent}.item-input .button .icon{-webkit-box-flex:0;-webkit-flex:0 0 24px;-moz-box-flex:0;-moz-flex:0 0 24px;-ms-flex:0 0 24px;flex:0 0 24px;position:static;display:inline-block;height:auto;text-align:center;font-size:16px}.item-input .button-bar{-webkit-border-radius:0;border-radius:0;-webkit-box-flex:1;-webkit-flex:1 0 220px;-moz-box-flex:1;-moz-flex:1 0 220px;-ms-flex:1 0 220px;flex:1 0 220px;-webkit-appearance:none;-moz-appearance:none;appearance:none}.item-input .icon{min-width:14px}.platform-windowsphone .item-input input{flex-shrink:1}.item-input-inset{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;position:relative;overflow:hidden;padding:10.67px}.item-input-wrapper{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-webkit-flex:1 0;-moz-box-flex:1;-moz-flex:1 0;-ms-flex:1 0;flex:1 0;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;-webkit-border-radius:4px;border-radius:4px;padding-right:8px;padding-left:8px;background:#eee}.item-input-inset .item-input-wrapper input{padding-left:4px;height:29px;background:0 0;line-height:18px}.item-input-wrapper~.button{margin-left:10.67px}.input-label{display:table;padding:7px 10px 7px 0;max-width:200px;width:35%;color:#444;font-size:16px}.placeholder-icon{color:#aaa}.placeholder-icon:first-child{padding-right:6px}.placeholder-icon:last-child{padding-left:6px}.item-stacked-label{display:block;background-color:transparent;box-shadow:none}.item-stacked-label .icon,.item-stacked-label .input-label{display:inline-block;padding:4px 0 0 0;vertical-align:middle}.item-stacked-label input,.item-stacked-label textarea{-webkit-border-radius:2px;border-radius:2px;padding:4px 8px 3px 0;border:none;background-color:#fff}.item-stacked-label input{overflow:hidden;height:46px}.item-select.item-stacked-label select{position:relative;padding:0;max-width:90%;direction:ltr;white-space:pre-wrap;margin:-3px}.item-floating-label{display:block;background-color:transparent;box-shadow:none}.item-floating-label .input-label{position:relative;padding:5px 0 0 0;opacity:0;top:10px;-webkit-transition:opacity .15s ease-in,top .2s linear;transition:opacity .15s ease-in,top .2s linear}.item-floating-label .input-label.has-input{opacity:1;top:0;-webkit-transition:opacity .15s ease-in,top .2s linear;transition:opacity .15s ease-in,top .2s linear}input[type=search],input[type=text],input[type=password],input[type=datetime],input[type=datetime-local],input[type=date],input[type=month],input[type=time],input[type=week],input[type=number],input[type=email],input[type=url],input[type=tel],input[type=color],textarea{display:block;padding-top:2px;padding-left:0;height:34px;color:#111;vertical-align:middle;font-size:14px;line-height:16px}.platform-android input[type=datetime-local],.platform-android input[type=date],.platform-android input[type=month],.platform-android input[type=time],.platform-android input[type=week],.platform-ios input[type=datetime-local],.platform-ios input[type=date],.platform-ios input[type=month],.platform-ios input[type=time],.platform-ios input[type=week]{padding-top:8px}.item-input input,.item-input textarea{width:100%}textarea{padding-left:0}textarea::-moz-placeholder{color:#aaa}textarea:-ms-input-placeholder{color:#aaa}textarea::-webkit-input-placeholder{color:#aaa;text-indent:-3px}textarea{height:auto}input[type=search],input[type=text],input[type=password],input[type=datetime],input[type=datetime-local],input[type=date],input[type=month],input[type=time],input[type=week],input[type=number],input[type=email],input[type=url],input[type=tel],input[type=color],textarea{border:0}input[type=radio],input[type=checkbox]{margin:0;line-height:normal}.item-input input[type=button],.item-input input[type=reset],.item-input input[type=submit],.item-input input[type=radio],.item-input input[type=checkbox],.item-input input[type=file],.item-input input[type=image]{width:auto}input[type=file]{line-height:34px}.cloned-text-input+input,.cloned-text-input+textarea,.previous-input-focus{position:absolute!important;left:-9999px;width:200px}input::-moz-placeholder,textarea::-moz-placeholder{color:#aaa}input:-ms-input-placeholder,textarea:-ms-input-placeholder{color:#aaa}input::-webkit-input-placeholder,textarea::-webkit-input-placeholder{color:#aaa;text-indent:0}input[disabled],input[readonly]:not(.cloned-text-input),select[disabled],select[readonly],textarea[disabled],textarea[readonly]:not(.cloned-text-input){background-color:#f8f8f8;cursor:not-allowed}input[type=radio][disabled],input[type=radio][readonly],input[type=checkbox][disabled],input[type=checkbox][readonly]{background-color:transparent}.checkbox{position:relative;display:inline-block;padding:7px 7px;cursor:pointer}.checkbox .checkbox-icon:before,.checkbox input:before{border-color:#ddd}.checkbox input:checked+.checkbox-icon:before,.checkbox input:checked:before{background:#387ef5;border-color:#387ef5}.checkbox-light .checkbox-icon:before,.checkbox-light input:before{border-color:#ddd}.checkbox-light input:checked+.checkbox-icon:before,.checkbox-light input:checked:before{background:#ddd;border-color:#ddd}.checkbox-stable .checkbox-icon:before,.checkbox-stable input:before{border-color:#b2b2b2}.checkbox-stable input:checked+.checkbox-icon:before,.checkbox-stable input:checked:before{background:#b2b2b2;border-color:#b2b2b2}.checkbox-positive .checkbox-icon:before,.checkbox-positive input:before{border-color:#387ef5}.checkbox-positive input:checked+.checkbox-icon:before,.checkbox-positive input:checked:before{background:#387ef5;border-color:#387ef5}.checkbox-calm .checkbox-icon:before,.checkbox-calm input:before{border-color:#11c1f3}.checkbox-calm input:checked+.checkbox-icon:before,.checkbox-calm input:checked:before{background:#11c1f3;border-color:#11c1f3}.checkbox-assertive .checkbox-icon:before,.checkbox-assertive input:before{border-color:#ef473a}.checkbox-assertive input:checked+.checkbox-icon:before,.checkbox-assertive input:checked:before{background:#ef473a;border-color:#ef473a}.checkbox-balanced .checkbox-icon:before,.checkbox-balanced input:before{border-color:#33cd5f}.checkbox-balanced input:checked+.checkbox-icon:before,.checkbox-balanced input:checked:before{background:#33cd5f;border-color:#33cd5f}.checkbox-energized .checkbox-icon:before,.checkbox-energized input:before{border-color:#ffc900}.checkbox-energized input:checked+.checkbox-icon:before,.checkbox-energized input:checked:before{background:#ffc900;border-color:#ffc900}.checkbox-royal .checkbox-icon:before,.checkbox-royal input:before{border-color:#886aea}.checkbox-royal input:checked+.checkbox-icon:before,.checkbox-royal input:checked:before{background:#886aea;border-color:#886aea}.checkbox-dark .checkbox-icon:before,.checkbox-dark input:before{border-color:#444}.checkbox-dark input:checked+.checkbox-icon:before,.checkbox-dark input:checked:before{background:#444;border-color:#444}.checkbox input:disabled+.checkbox-icon:before,.checkbox input:disabled:before{border-color:#ddd}.checkbox input:disabled:checked+.checkbox-icon:before,.checkbox input:disabled:checked:before{background:#ddd}.checkbox.checkbox-input-hidden input{display:none!important}.checkbox input,.checkbox-icon{position:relative;width:28px;height:28px;display:block;border:0;background:0 0;cursor:pointer;-webkit-appearance:none}.checkbox input:before,.checkbox-icon:before{display:table;width:100%;height:100%;border-width:1px;border-style:solid;border-radius:28px;background:#fff;content:' ';-webkit-transition:background-color 20ms ease-in-out;transition:background-color 20ms ease-in-out}.checkbox input:checked:before,input:checked+.checkbox-icon:before{border-width:2px}.checkbox input:after,.checkbox-icon:after{-webkit-transition:opacity .05s ease-in-out;transition:opacity .05s ease-in-out;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);position:absolute;top:33%;left:25%;display:table;width:14px;height:6px;border:1px solid #fff;border-top:0;border-right:0;content:' ';opacity:0}.checkbox-square .checkbox-icon:before,.checkbox-square input:before,.platform-android .checkbox-platform .checkbox-icon:before,.platform-android .checkbox-platform input:before{border-radius:2px;width:72%;height:72%;margin-top:14%;margin-left:14%;border-width:2px}.checkbox-square .checkbox-icon:after,.checkbox-square input:after,.platform-android .checkbox-platform .checkbox-icon:after,.platform-android .checkbox-platform input:after{border-width:2px;top:19%;left:25%;width:13px;height:7px}.platform-android .item-checkbox-right .checkbox-square .checkbox-icon::after{top:31%}.grade-c .checkbox input:after,.grade-c .checkbox-icon:after{-webkit-transform:rotate(0);transform:rotate(0);top:3px;left:4px;border:none;color:#fff;content:'\2713';font-weight:700;font-size:20px}.checkbox input:checked:after,input:checked+.checkbox-icon:after{opacity:1}.item-checkbox{padding-left:60px}.item-checkbox.active{box-shadow:none}.item-checkbox .checkbox{position:absolute;top:50%;right:8px;left:8px;z-index:3;margin-top:-21px}.item-checkbox.item-checkbox-right{padding-right:60px;padding-left:16px}.item-checkbox-right .checkbox input,.item-checkbox-right .checkbox-icon{float:right}.item-toggle{pointer-events:none}.toggle{position:relative;display:inline-block;pointer-events:auto;margin:-5px;padding:5px}.toggle input:checked+.track{border-color:#4cd964;background-color:#4cd964}.toggle.dragging .handle{background-color:#f2f2f2!important}.toggle.toggle-light input:checked+.track{border-color:#ddd;background-color:#ddd}.toggle.toggle-stable input:checked+.track{border-color:#b2b2b2;background-color:#b2b2b2}.toggle.toggle-positive input:checked+.track{border-color:#387ef5;background-color:#387ef5}.toggle.toggle-calm input:checked+.track{border-color:#11c1f3;background-color:#11c1f3}.toggle.toggle-assertive input:checked+.track{border-color:#ef473a;background-color:#ef473a}.toggle.toggle-balanced input:checked+.track{border-color:#33cd5f;background-color:#33cd5f}.toggle.toggle-energized input:checked+.track{border-color:#ffc900;background-color:#ffc900}.toggle.toggle-royal input:checked+.track{border-color:#886aea;background-color:#886aea}.toggle.toggle-dark input:checked+.track{border-color:#444;background-color:#444}.toggle input{display:none}.toggle .track{-webkit-transition-timing-function:ease-in-out;transition-timing-function:ease-in-out;-webkit-transition-duration:.3s;transition-duration:.3s;-webkit-transition-property:background-color,border;transition-property:background-color,border;display:inline-block;box-sizing:border-box;width:51px;height:31px;border:solid 2px #e6e6e6;border-radius:20px;background-color:#fff;content:' ';cursor:pointer;pointer-events:none}.platform-android4_2 .toggle .track{-webkit-background-clip:padding-box}.toggle .handle{-webkit-transition:.3s cubic-bezier(0,1.1,1,1.1);transition:.3s cubic-bezier(0,1.1,1,1.1);-webkit-transition-property:background-color,transform;transition-property:background-color,transform;position:absolute;display:block;width:27px;height:27px;border-radius:27px;background-color:#fff;top:7px;left:7px;box-shadow:0 2px 7px rgba(0,0,0,.35),0 1px 1px rgba(0,0,0,.15)}.toggle .handle:before{position:absolute;top:-4px;left:-21.5px;padding:18.5px 34px;content:" "}.toggle input:checked+.track .handle{-webkit-transform:translate3d(20px,0,0);transform:translate3d(20px,0,0);background-color:#fff}.item-toggle.active{box-shadow:none}.item-toggle,.item-toggle.item-complex .item-content{padding-right:99px}.item-toggle.item-complex{padding-right:0}.item-toggle .toggle{position:absolute;top:10px;right:16px;z-index:3}.toggle input:disabled+.track{opacity:.6}.toggle-small .track{border:0;width:34px;height:15px;background:#9e9e9e}.toggle-small input:checked+.track{background:rgba(0,150,137,.5)}.toggle-small .handle{top:2px;left:4px;width:21px;height:21px;box-shadow:0 2px 5px rgba(0,0,0,.25)}.toggle-small input:checked+.track .handle{-webkit-transform:translate3d(16px,0,0);transform:translate3d(16px,0,0);background:#009689}.toggle-small.item-toggle .toggle{top:19px}.toggle-small .toggle-light input:checked+.track{background-color:rgba(221,221,221,.5)}.toggle-small .toggle-light input:checked+.track .handle{background-color:#ddd}.toggle-small .toggle-stable input:checked+.track{background-color:rgba(178,178,178,.5)}.toggle-small .toggle-stable input:checked+.track .handle{background-color:#b2b2b2}.toggle-small .toggle-positive input:checked+.track{background-color:rgba(56,126,245,.5)}.toggle-small .toggle-positive input:checked+.track .handle{background-color:#387ef5}.toggle-small .toggle-calm input:checked+.track{background-color:rgba(17,193,243,.5)}.toggle-small .toggle-calm input:checked+.track .handle{background-color:#11c1f3}.toggle-small .toggle-assertive input:checked+.track{background-color:rgba(239,71,58,.5)}.toggle-small .toggle-assertive input:checked+.track .handle{background-color:#ef473a}.toggle-small .toggle-balanced input:checked+.track{background-color:rgba(51,205,95,.5)}.toggle-small .toggle-balanced input:checked+.track .handle{background-color:#33cd5f}.toggle-small .toggle-energized input:checked+.track{background-color:rgba(255,201,0,.5)}.toggle-small .toggle-energized input:checked+.track .handle{background-color:#ffc900}.toggle-small .toggle-royal input:checked+.track{background-color:rgba(136,106,234,.5)}.toggle-small .toggle-royal input:checked+.track .handle{background-color:#886aea}.toggle-small .toggle-dark input:checked+.track{background-color:rgba(68,68,68,.5)}.toggle-small .toggle-dark input:checked+.track .handle{background-color:#444}.item-radio{padding:0}.item-radio:hover{cursor:pointer}.item-radio .item-content{padding-right:64px}.item-radio .radio-icon{position:absolute;top:0;right:0;z-index:3;visibility:hidden;padding:14px;height:100%;font-size:24px}.item-radio input{position:absolute;left:-9999px}.item-radio input:checked+.radio-content .item-content{background:#f7f7f7}.item-radio input:checked+.radio-content .radio-icon{visibility:visible}.range input{overflow:hidden;margin-top:5px;margin-bottom:5px;padding-right:2px;padding-left:1px;width:auto;height:43px;outline:0;background:-webkit-gradient(linear,50% 0,50% 100%,color-stop(0,#ccc),color-stop(100%,#ccc));background:linear-gradient(to right,#ccc 0,#ccc 100%);background-position:center;background-size:99% 2px;background-repeat:no-repeat;-webkit-appearance:none}.range input::-moz-focus-outer{border:0}.range input::-webkit-slider-thumb{position:relative;width:28px;height:28px;border-radius:50%;background-color:#fff;box-shadow:0 0 2px rgba(0,0,0,.3),0 3px 5px rgba(0,0,0,.2);cursor:pointer;-webkit-appearance:none;border:0}.range input::-webkit-slider-thumb:before{position:absolute;top:13px;left:-2001px;width:2000px;height:2px;background:#444;content:' '}.range input::-webkit-slider-thumb:after{position:absolute;top:-15px;left:-15px;padding:30px;content:' '}.range input::-ms-fill-lower{height:2px;background:#444}.range{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center;padding:2px 11px}.range.range-light input::-webkit-slider-thumb:before{background:#ddd}.range.range-light input::-ms-fill-lower{background:#ddd}.range.range-stable input::-webkit-slider-thumb:before{background:#b2b2b2}.range.range-stable input::-ms-fill-lower{background:#b2b2b2}.range.range-positive input::-webkit-slider-thumb:before{background:#387ef5}.range.range-positive input::-ms-fill-lower{background:#387ef5}.range.range-calm input::-webkit-slider-thumb:before{background:#11c1f3}.range.range-calm input::-ms-fill-lower{background:#11c1f3}.range.range-balanced input::-webkit-slider-thumb:before{background:#33cd5f}.range.range-balanced input::-ms-fill-lower{background:#33cd5f}.range.range-assertive input::-webkit-slider-thumb:before{background:#ef473a}.range.range-assertive input::-ms-fill-lower{background:#ef473a}.range.range-energized input::-webkit-slider-thumb:before{background:#ffc900}.range.range-energized input::-ms-fill-lower{background:#ffc900}.range.range-royal input::-webkit-slider-thumb:before{background:#886aea}.range.range-royal input::-ms-fill-lower{background:#886aea}.range.range-dark input::-webkit-slider-thumb:before{background:#444}.range.range-dark input::-ms-fill-lower{background:#444}.range .icon{-webkit-box-flex:0;-webkit-flex:0;-moz-box-flex:0;-moz-flex:0;-ms-flex:0;flex:0;display:block;min-width:24px;text-align:center;font-size:24px}.range input{-webkit-box-flex:1;-webkit-flex:1;-moz-box-flex:1;-moz-flex:1;-ms-flex:1;flex:1;display:block;margin-right:10px;margin-left:10px}.range-label{-webkit-box-flex:0;-webkit-flex:0 0 auto;-moz-box-flex:0;-moz-flex:0 0 auto;-ms-flex:0 0 auto;flex:0 0 auto;display:block;white-space:nowrap}.range-label:first-child{padding-left:5px}.range input+.range-label{padding-right:5px;padding-left:0}.platform-windowsphone .range input{height:auto}.item-select{position:relative}.item-select select{-webkit-appearance:none;-moz-appearance:none;appearance:none;position:absolute;top:0;bottom:0;right:0;padding:0 48px 0 16px;max-width:65%;border:none;background:#fff;color:#333;text-indent:.01px;text-overflow:'';white-space:nowrap;font-size:14px;cursor:pointer;direction:rtl}.item-select select::-ms-expand{display:none}.item-select option{direction:ltr}.item-select:after{position:absolute;top:50%;right:16px;margin-top:-3px;width:0;height:0;border-top:5px solid;border-right:5px solid transparent;border-left:5px solid transparent;color:#999;content:"";pointer-events:none}.item-select.item-light select{background:#fff;color:#444}.item-select.item-stable select{background:#f8f8f8;color:#444}.item-select.item-stable .input-label,.item-select.item-stable:after{color:#666}.item-select.item-positive select{background:#387ef5;color:#fff}.item-select.item-positive .input-label,.item-select.item-positive:after{color:#fff}.item-select.item-calm select{background:#11c1f3;color:#fff}.item-select.item-calm .input-label,.item-select.item-calm:after{color:#fff}.item-select.item-assertive select{background:#ef473a;color:#fff}.item-select.item-assertive .input-label,.item-select.item-assertive:after{color:#fff}.item-select.item-balanced select{background:#33cd5f;color:#fff}.item-select.item-balanced .input-label,.item-select.item-balanced:after{color:#fff}.item-select.item-energized select{background:#ffc900;color:#fff}.item-select.item-energized .input-label,.item-select.item-energized:after{color:#fff}.item-select.item-royal select{background:#886aea;color:#fff}.item-select.item-royal .input-label,.item-select.item-royal:after{color:#fff}.item-select.item-dark select{background:#444;color:#fff}.item-select.item-dark .input-label,.item-select.item-dark:after{color:#fff}select[multiple],select[size]{height:auto}progress{display:block;margin:15px auto;width:100%}.button{border-color:transparent;background-color:#f8f8f8;color:#444;position:relative;display:inline-block;margin:0;padding:0 12px;min-width:52px;min-height:47px;border-width:1px;border-style:solid;border-radius:4px;vertical-align:top;text-align:center;text-overflow:ellipsis;font-size:16px;line-height:42px;cursor:pointer}.button:hover{color:#444;text-decoration:none}.button.activated,.button.active{border-color:#a2a2a2;background-color:#e5e5e5}.button:after{position:absolute;top:-6px;right:-6px;bottom:-6px;left:-6px;content:' '}.button .icon{vertical-align:top;pointer-events:none}.button .icon:before,.button.icon-left:before,.button.icon-right:before,.button.icon:before{display:inline-block;padding:0 0 1px 0;vertical-align:inherit;font-size:24px;line-height:41px;pointer-events:none}.button.icon-left:before{float:left;padding-right:.2em;padding-left:0}.button.icon-right:before{float:right;padding-right:0;padding-left:.2em}.button.button-block,.button.button-full{margin-top:10px;margin-bottom:10px}.button.button-light{border-color:transparent;background-color:#fff;color:#444}.button.button-light:hover{color:#444;text-decoration:none}.button.button-light.activated,.button.button-light.active{border-color:#a2a2a2;background-color:#fafafa}.button.button-light.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#ddd}.button.button-light.button-icon{border-color:transparent;background:0 0}.button.button-light.button-outline{border-color:#ddd;background:0 0;color:#ddd}.button.button-light.button-outline.activated,.button.button-light.button-outline.active{background-color:#ddd;box-shadow:none;color:#fff}.button.button-stable{border-color:transparent;background-color:#f8f8f8;color:#444}.button.button-stable:hover{color:#444;text-decoration:none}.button.button-stable.activated,.button.button-stable.active{border-color:#a2a2a2;background-color:#e5e5e5}.button.button-stable.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#b2b2b2}.button.button-stable.button-icon{border-color:transparent;background:0 0}.button.button-stable.button-outline{border-color:#b2b2b2;background:0 0;color:#b2b2b2}.button.button-stable.button-outline.activated,.button.button-stable.button-outline.active{background-color:#b2b2b2;box-shadow:none;color:#fff}.button.button-positive{border-color:transparent;background-color:#387ef5;color:#fff}.button.button-positive:hover{color:#fff;text-decoration:none}.button.button-positive.activated,.button.button-positive.active{border-color:#a2a2a2;background-color:#0c60ee}.button.button-positive.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#387ef5}.button.button-positive.button-icon{border-color:transparent;background:0 0}.button.button-positive.button-outline{border-color:#387ef5;background:0 0;color:#387ef5}.button.button-positive.button-outline.activated,.button.button-positive.button-outline.active{background-color:#387ef5;box-shadow:none;color:#fff}.button.button-calm{border-color:transparent;background-color:#11c1f3;color:#fff}.button.button-calm:hover{color:#fff;text-decoration:none}.button.button-calm.activated,.button.button-calm.active{border-color:#a2a2a2;background-color:#0a9dc7}.button.button-calm.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#11c1f3}.button.button-calm.button-icon{border-color:transparent;background:0 0}.button.button-calm.button-outline{border-color:#11c1f3;background:0 0;color:#11c1f3}.button.button-calm.button-outline.activated,.button.button-calm.button-outline.active{background-color:#11c1f3;box-shadow:none;color:#fff}.button.button-assertive{border-color:transparent;background-color:#ef473a;color:#fff}.button.button-assertive:hover{color:#fff;text-decoration:none}.button.button-assertive.activated,.button.button-assertive.active{border-color:#a2a2a2;background-color:#e42112}.button.button-assertive.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#ef473a}.button.button-assertive.button-icon{border-color:transparent;background:0 0}.button.button-assertive.button-outline{border-color:#ef473a;background:0 0;color:#ef473a}.button.button-assertive.button-outline.activated,.button.button-assertive.button-outline.active{background-color:#ef473a;box-shadow:none;color:#fff}.button.button-balanced{border-color:transparent;background-color:#33cd5f;color:#fff}.button.button-balanced:hover{color:#fff;text-decoration:none}.button.button-balanced.activated,.button.button-balanced.active{border-color:#a2a2a2;background-color:#28a54c}.button.button-balanced.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#33cd5f}.button.button-balanced.button-icon{border-color:transparent;background:0 0}.button.button-balanced.button-outline{border-color:#33cd5f;background:0 0;color:#33cd5f}.button.button-balanced.button-outline.activated,.button.button-balanced.button-outline.active{background-color:#33cd5f;box-shadow:none;color:#fff}.button.button-energized{border-color:transparent;background-color:#ffc900;color:#fff}.button.button-energized:hover{color:#fff;text-decoration:none}.button.button-energized.activated,.button.button-energized.active{border-color:#a2a2a2;background-color:#e6b500}.button.button-energized.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#ffc900}.button.button-energized.button-icon{border-color:transparent;background:0 0}.button.button-energized.button-outline{border-color:#ffc900;background:0 0;color:#ffc900}.button.button-energized.button-outline.activated,.button.button-energized.button-outline.active{background-color:#ffc900;box-shadow:none;color:#fff}.button.button-royal{border-color:transparent;background-color:#886aea;color:#fff}.button.button-royal:hover{color:#fff;text-decoration:none}.button.button-royal.activated,.button.button-royal.active{border-color:#a2a2a2;background-color:#6b46e5}.button.button-royal.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#886aea}.button.button-royal.button-icon{border-color:transparent;background:0 0}.button.button-royal.button-outline{border-color:#886aea;background:0 0;color:#886aea}.button.button-royal.button-outline.activated,.button.button-royal.button-outline.active{background-color:#886aea;box-shadow:none;color:#fff}.button.button-dark{border-color:transparent;background-color:#444;color:#fff}.button.button-dark:hover{color:#fff;text-decoration:none}.button.button-dark.activated,.button.button-dark.active{border-color:#a2a2a2;background-color:#262626}.button.button-dark.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:#444}.button.button-dark.button-icon{border-color:transparent;background:0 0}.button.button-dark.button-outline{border-color:#444;background:0 0;color:#444}.button.button-dark.button-outline.activated,.button.button-dark.button-outline.active{background-color:#444;box-shadow:none;color:#fff}.button-small{padding:2px 4px 1px;min-width:28px;min-height:30px;font-size:12px;line-height:26px}.button-small .icon:before,.button-small.icon-left:before,.button-small.icon-right:before,.button-small.icon:before{font-size:16px;line-height:19px;margin-top:3px}.button-large{padding:0 16px;min-width:68px;min-height:59px;font-size:20px;line-height:53px}.button-large .icon:before,.button-large.icon-left:before,.button-large.icon-right:before,.button-large.icon:before{padding-bottom:2px;font-size:32px;line-height:51px}.button-icon{-webkit-transition:opacity .1s;transition:opacity .1s;padding:0 6px;min-width:initial;border-color:transparent;background:0 0}.button-icon.button.activated,.button-icon.button.active{border-color:transparent;background:0 0;box-shadow:none;opacity:.3}.button-icon .icon:before,.button-icon.icon:before{font-size:32px}.button-clear{-webkit-transition:opacity .1s;transition:opacity .1s;padding:0 6px;max-height:42px;border-color:transparent;background:0 0;box-shadow:none}.button-clear.button-clear{border-color:transparent;background:0 0;box-shadow:none;color:transparent}.button-clear.button-icon{border-color:transparent;background:0 0}.button-clear.activated,.button-clear.active{opacity:.3}.button-outline{-webkit-transition:opacity .1s;transition:opacity .1s;background:0 0;box-shadow:none}.button-outline.button-outline{border-color:transparent;background:0 0;color:transparent}.button-outline.button-outline.activated,.button-outline.button-outline.active{background-color:transparent;box-shadow:none;color:#fff}.padding>.button.button-block:first-child{margin-top:0}.button-block{display:block;clear:both}.button-block:after{clear:both}.button-full,.button-full>.button{display:block;margin-right:0;margin-left:0;border-right-width:0;border-left-width:0;border-radius:0}.button-full>button.button,button.button-block,button.button-full,input.button.button-block{width:100%}a.button{text-decoration:none}a.button .icon:before,a.button.icon-left:before,a.button.icon-right:before,a.button.icon:before{margin-top:2px}.button.disabled,.button[disabled]{opacity:.4;cursor:default!important;pointer-events:none}.button-bar{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-webkit-flex:1;-moz-box-flex:1;-moz-flex:1;-ms-flex:1;flex:1;width:100%}.button-bar.button-bar-inline{display:block;width:auto}.button-bar.button-bar-inline:after,.button-bar.button-bar-inline:before{display:table;content:"";line-height:0}.button-bar.button-bar-inline:after{clear:both}.button-bar.button-bar-inline>.button{width:auto;display:inline-block;float:left}.button-bar.bar-light>.button{border-color:#ddd}.button-bar.bar-stable>.button{border-color:#b2b2b2}.button-bar.bar-positive>.button{border-color:#0c60ee}.button-bar.bar-calm>.button{border-color:#0a9dc7}.button-bar.bar-assertive>.button{border-color:#e42112}.button-bar.bar-balanced>.button{border-color:#28a54c}.button-bar.bar-energized>.button{border-color:#e6b500}.button-bar.bar-royal>.button{border-color:#6b46e5}.button-bar.bar-dark>.button{border-color:#111}.button-bar>.button{-webkit-box-flex:1;-webkit-flex:1;-moz-box-flex:1;-moz-flex:1;-ms-flex:1;flex:1;display:block;overflow:hidden;padding:0 16px;width:0;border-width:1px 0 1px 1px;border-radius:0;text-align:center;text-overflow:ellipsis;white-space:nowrap}.button-bar>.button .icon:before,.button-bar>.button:before{line-height:44px}.button-bar>.button:first-child{border-radius:4px 0 0 4px}.button-bar>.button:last-child{border-right-width:1px;border-radius:0 4px 4px 0}.button-bar>.button:only-child{border-radius:4px}.button-bar>.button-small .icon:before,.button-bar>.button-small:before{line-height:28px}.row{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:-moz-flex;display:-ms-flexbox;display:flex;padding:5px;width:100%}.row-wrap{-webkit-flex-wrap:wrap;-moz-flex-wrap:wrap;-ms-flex-wrap:wrap;flex-wrap:wrap}.row-no-padding,.row-no-padding>.col{padding:0}.row+.row{margin-top:-5px;padding-top:0}.col{-webkit-box-flex:1;-webkit-flex:1;-moz-box-flex:1;-moz-flex:1;-ms-flex:1;flex:1;display:block;padding:5px;width:100%}.row-top{-webkit-box-align:start;-ms-flex-align:start;-webkit-align-items:flex-start;-moz-align-items:flex-start;align-items:flex-start}.row-bottom{-webkit-box-align:end;-ms-flex-align:end;-webkit-align-items:flex-end;-moz-align-items:flex-end;align-items:flex-end}.row-center{-webkit-box-align:center;-ms-flex-align:center;-webkit-align-items:center;-moz-align-items:center;align-items:center}.row-stretch{-webkit-box-align:stretch;-ms-flex-align:stretch;-webkit-align-items:stretch;-moz-align-items:stretch;align-items:stretch}.row-baseline{-webkit-box-align:baseline;-ms-flex-align:baseline;-webkit-align-items:baseline;-moz-align-items:baseline;align-items:baseline}.col-top{-webkit-align-self:flex-start;-moz-align-self:flex-start;-ms-flex-item-align:start;align-self:flex-start}.col-bottom{-webkit-align-self:flex-end;-moz-align-self:flex-end;-ms-flex-item-align:end;align-self:flex-end}.col-center{-webkit-align-self:center;-moz-align-self:center;-ms-flex-item-align:center;align-self:center}.col-offset-10{margin-left:10%}.col-offset-20{margin-left:20%}.col-offset-25{margin-left:25%}.col-offset-33,.col-offset-34{margin-left:33.3333%}.col-offset-50{margin-left:50%}.col-offset-66,.col-offset-67{margin-left:66.6666%}.col-offset-75{margin-left:75%}.col-offset-80{margin-left:80%}.col-offset-90{margin-left:90%}.col-10{-webkit-box-flex:0;-webkit-flex:0 0 10%;-moz-box-flex:0;-moz-flex:0 0 10%;-ms-flex:0 0 10%;flex:0 0 10%;max-width:10%}.col-20{-webkit-box-flex:0;-webkit-flex:0 0 20%;-moz-box-flex:0;-moz-flex:0 0 20%;-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.col-25{-webkit-box-flex:0;-webkit-flex:0 0 25%;-moz-box-flex:0;-moz-flex:0 0 25%;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-33,.col-34{-webkit-box-flex:0;-webkit-flex:0 0 33.3333%;-moz-box-flex:0;-moz-flex:0 0 33.3333%;-ms-flex:0 0 33.3333%;flex:0 0 33.3333%;max-width:33.3333%}.col-40{-webkit-box-flex:0;-webkit-flex:0 0 40%;-moz-box-flex:0;-moz-flex:0 0 40%;-ms-flex:0 0 40%;flex:0 0 40%;max-width:40%}.col-50{-webkit-box-flex:0;-webkit-flex:0 0 50%;-moz-box-flex:0;-moz-flex:0 0 50%;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-60{-webkit-box-flex:0;-webkit-flex:0 0 60%;-moz-box-flex:0;-moz-flex:0 0 60%;-ms-flex:0 0 60%;flex:0 0 60%;max-width:60%}.col-66,.col-67{-webkit-box-flex:0;-webkit-flex:0 0 66.6666%;-moz-box-flex:0;-moz-flex:0 0 66.6666%;-ms-flex:0 0 66.6666%;flex:0 0 66.6666%;max-width:66.6666%}.col-75{-webkit-box-flex:0;-webkit-flex:0 0 75%;-moz-box-flex:0;-moz-flex:0 0 75%;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-80{-webkit-box-flex:0;-webkit-flex:0 0 80%;-moz-box-flex:0;-moz-flex:0 0 80%;-ms-flex:0 0 80%;flex:0 0 80%;max-width:80%}.col-90{-webkit-box-flex:0;-webkit-flex:0 0 90%;-moz-box-flex:0;-moz-flex:0 0 90%;-ms-flex:0 0 90%;flex:0 0 90%;max-width:90%}@media (max-width:567px){.responsive-sm{-webkit-box-direction:normal;-moz-box-direction:normal;-webkit-box-orient:vertical;-moz-box-orient:vertical;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column}.responsive-sm .col,.responsive-sm .col-10,.responsive-sm .col-20,.responsive-sm .col-25,.responsive-sm .col-33,.responsive-sm .col-34,.responsive-sm .col-50,.responsive-sm .col-66,.responsive-sm .col-67,.responsive-sm .col-75,.responsive-sm .col-80,.responsive-sm .col-90{-webkit-box-flex:1;-webkit-flex:1;-moz-box-flex:1;-moz-flex:1;-ms-flex:1;flex:1;margin-bottom:15px;margin-left:0;max-width:100%;width:100%}}@media (max-width:767px){.responsive-md{-webkit-box-direction:normal;-moz-box-direction:normal;-webkit-box-orient:vertical;-moz-box-orient:vertical;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column}.responsive-md .col,.responsive-md .col-10,.responsive-md .col-20,.responsive-md .col-25,.responsive-md .col-33,.responsive-md .col-34,.responsive-md .col-50,.responsive-md .col-66,.responsive-md .col-67,.responsive-md .col-75,.responsive-md .col-80,.responsive-md .col-90{-webkit-box-flex:1;-webkit-flex:1;-moz-box-flex:1;-moz-flex:1;-ms-flex:1;flex:1;margin-bottom:15px;margin-left:0;max-width:100%;width:100%}}@media (max-width:1023px){.responsive-lg{-webkit-box-direction:normal;-moz-box-direction:normal;-webkit-box-orient:vertical;-moz-box-orient:vertical;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column}.responsive-lg .col,.responsive-lg .col-10,.responsive-lg .col-20,.responsive-lg .col-25,.responsive-lg .col-33,.responsive-lg .col-34,.responsive-lg .col-50,.responsive-lg .col-66,.responsive-lg .col-67,.responsive-lg .col-75,.responsive-lg .col-80,.responsive-lg .col-90{-webkit-box-flex:1;-webkit-flex:1;-moz-box-flex:1;-moz-flex:1;-ms-flex:1;flex:1;margin-bottom:15px;margin-left:0;max-width:100%;width:100%}}.hide{display:none}.opacity-hide{opacity:0}.grade-b .opacity-hide,.grade-c .opacity-hide{opacity:1;display:none}.show{display:block}.opacity-show{opacity:1}.invisible{visibility:hidden}.keyboard-open .hide-on-keyboard-open{display:none}.keyboard-open .bar-footer.hide-on-keyboard-open+.pane .has-footer,.keyboard-open .tabs.hide-on-keyboard-open+.pane .has-tabs{bottom:0}.inline{display:inline-block}.disable-pointer-events{pointer-events:none}.enable-pointer-events{pointer-events:auto}.disable-user-behavior{-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;-webkit-touch-callout:none;-webkit-tap-highlight-color:transparent;-webkit-tap-highlight-color:transparent;-webkit-user-drag:none;-ms-touch-action:none;-ms-content-zooming:none}.click-block{position:absolute;top:0;right:0;bottom:0;left:0;opacity:0;z-index:99999;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0);overflow:hidden}.click-block-hide{-webkit-transform:translate3d(-9999px,0,0);transform:translate3d(-9999px,0,0)}.no-resize{resize:none}.block{display:block;clear:both}.block:after{display:block;visibility:hidden;clear:both;height:0;content:"."}.full-image{width:100%}.clearfix:after,.clearfix:before{display:table;content:"";line-height:0}.clearfix:after{clear:both}.padding{padding:10px}.padding-top,.padding-vertical{padding-top:10px}.padding-horizontal,.padding-right{padding-right:10px}.padding-bottom,.padding-vertical{padding-bottom:10px}.padding-horizontal,.padding-left{padding-left:10px}.iframe-wrapper{position:fixed;-webkit-overflow-scrolling:touch;overflow:scroll}.iframe-wrapper iframe{height:100%;width:100%}.rounded{border-radius:4px}.light,a.light{color:#fff}.light-bg{background-color:#fff}.light-border{border-color:#ddd}.stable,a.stable{color:#f8f8f8}.stable-bg{background-color:#f8f8f8}.stable-border{border-color:#b2b2b2}.positive,a.positive{color:#387ef5}.positive-bg{background-color:#387ef5}.positive-border{border-color:#0c60ee}.calm,a.calm{color:#11c1f3}.calm-bg{background-color:#11c1f3}.calm-border{border-color:#0a9dc7}.assertive,a.assertive{color:#ef473a}.assertive-bg{background-color:#ef473a}.assertive-border{border-color:#e42112}.balanced,a.balanced{color:#33cd5f}.balanced-bg{background-color:#33cd5f}.balanced-border{border-color:#28a54c}.energized,a.energized{color:#ffc900}.energized-bg{background-color:#ffc900}.energized-border{border-color:#e6b500}.royal,a.royal{color:#886aea}.royal-bg{background-color:#886aea}.royal-border{border-color:#6b46e5}.dark,a.dark{color:#444}.dark-bg{background-color:#444}.dark-border{border-color:#111}[collection-repeat]{left:0!important;top:0!important;position:absolute!important;z-index:1}.collection-repeat-container{position:relative;z-index:1}.collection-repeat-after-container{z-index:0;display:block}.collection-repeat-after-container.horizontal{display:inline-block}.ng-cloak,.ng-hide:not(.ng-hide-animate),.x-ng-cloak,[data-ng-cloak],[ng-cloak],[ng\:cloak],[x-ng-cloak]{display:none!important}.platform-ios.platform-cordova:not(.fullscreen) .bar-header:not(.bar-subheader){height:64px}.platform-ios.platform-cordova:not(.fullscreen) .bar-header:not(.bar-subheader).item-input-inset .item-input-wrapper{margin-top:19px!important}.platform-ios.platform-cordova:not(.fullscreen) .bar-header:not(.bar-subheader)>*{margin-top:20px}.platform-ios.platform-cordova:not(.fullscreen) .bar-subheader,.platform-ios.platform-cordova:not(.fullscreen) .has-header,.platform-ios.platform-cordova:not(.fullscreen) .tabs-top>.tabs,.platform-ios.platform-cordova:not(.fullscreen) .tabs.tabs-top{top:64px}.platform-ios.platform-cordova:not(.fullscreen) .has-subheader{top:108px}.platform-ios.platform-cordova:not(.fullscreen) .has-header.has-tabs-top{top:113px}.platform-ios.platform-cordova:not(.fullscreen) .has-header.has-subheader.has-tabs-top{top:157px}.platform-ios.platform-cordova .popover .bar-header:not(.bar-subheader){height:44px}.platform-ios.platform-cordova .popover .bar-header:not(.bar-subheader).item-input-inset .item-input-wrapper{margin-top:-1px}.platform-ios.platform-cordova .popover .bar-header:not(.bar-subheader)>*{margin-top:0}.platform-ios.platform-cordova .popover .bar-subheader,.platform-ios.platform-cordova .popover .has-header{top:44px}.platform-ios.platform-cordova .popover .has-subheader{top:88px}.platform-ios.platform-cordova.status-bar-hide{margin-bottom:20px}@media (orientation:landscape){.platform-ios.platform-browser.platform-ipad{position:fixed}}.platform-c:not(.enable-transitions) *{-webkit-transition:none!important;transition:none!important}.slide-in-up{-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}.slide-in-up.ng-enter,.slide-in-up>.ng-enter{-webkit-transition:all cubic-bezier(.1,.7,.1,1) 400ms;transition:all cubic-bezier(.1,.7,.1,1) 400ms}.slide-in-up.ng-enter-active,.slide-in-up>.ng-enter-active{-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}.slide-in-up.ng-leave,.slide-in-up>.ng-leave{-webkit-transition:all ease-in-out 250ms;transition:all ease-in-out 250ms}@-webkit-keyframes scaleOut{from{-webkit-transform:scale(1);opacity:1}to{-webkit-transform:scale(.8);opacity:0}}@keyframes scaleOut{from{transform:scale(1);opacity:1}to{transform:scale(.8);opacity:0}}@-webkit-keyframes superScaleIn{from{-webkit-transform:scale(1.2);opacity:0}to{-webkit-transform:scale(1);opacity:1}}@keyframes superScaleIn{from{transform:scale(1.2);opacity:0}to{transform:scale(1);opacity:1}}[nav-view-transition=ios] [nav-view=entering],[nav-view-transition=ios] [nav-view=leaving]{-webkit-transition-duration:500ms;transition-duration:500ms;-webkit-transition-timing-function:cubic-bezier(.36,.66,.04,1);transition-timing-function:cubic-bezier(.36,.66,.04,1);-webkit-transition-property:opacity,-webkit-transform,box-shadow;transition-property:opacity,transform,box-shadow}[nav-view-transition=ios][nav-view-direction=forward],[nav-view-transition=ios][nav-view-direction=back]{background-color:#000}[nav-view-transition=ios] [nav-view=active],[nav-view-transition=ios][nav-view-direction=forward] [nav-view=entering],[nav-view-transition=ios][nav-view-direction=back] [nav-view=leaving]{z-index:3}[nav-view-transition=ios][nav-view-direction=forward] [nav-view=leaving],[nav-view-transition=ios][nav-view-direction=back] [nav-view=entering]{z-index:2}[nav-bar-transition=ios] .back-text,[nav-bar-transition=ios] .buttons,[nav-bar-transition=ios] .title{-webkit-transition-duration:500ms;transition-duration:500ms;-webkit-transition-timing-function:cubic-bezier(.36,.66,.04,1);transition-timing-function:cubic-bezier(.36,.66,.04,1);-webkit-transition-property:opacity,-webkit-transform;transition-property:opacity,transform}[nav-bar-transition=ios] [nav-bar=entering],[nav-bar-transition=ios] [nav-bar=active]{z-index:10}[nav-bar-transition=ios] [nav-bar=entering] .bar,[nav-bar-transition=ios] [nav-bar=active] .bar{background:0 0}[nav-bar-transition=ios] [nav-bar=cached]{display:block}[nav-bar-transition=ios] [nav-bar=cached] .header-item{display:none}[nav-view-transition=android] [nav-view=entering],[nav-view-transition=android] [nav-view=leaving]{-webkit-transition-duration:200ms;transition-duration:200ms;-webkit-transition-timing-function:cubic-bezier(.4,.6,.2,1);transition-timing-function:cubic-bezier(.4,.6,.2,1);-webkit-transition-property:-webkit-transform;transition-property:transform}[nav-view-transition=android] [nav-view=active],[nav-view-transition=android][nav-view-direction=forward] [nav-view=entering],[nav-view-transition=android][nav-view-direction=back] [nav-view=leaving]{z-index:3}[nav-view-transition=android][nav-view-direction=forward] [nav-view=leaving],[nav-view-transition=android][nav-view-direction=back] [nav-view=entering]{z-index:2}[nav-bar-transition=android] .buttons,[nav-bar-transition=android] .title{-webkit-transition-duration:200ms;transition-duration:200ms;-webkit-transition-timing-function:cubic-bezier(.4,.6,.2,1);transition-timing-function:cubic-bezier(.4,.6,.2,1);-webkit-transition-property:opacity;transition-property:opacity}[nav-bar-transition=android] [nav-bar=entering],[nav-bar-transition=android] [nav-bar=active]{z-index:10}[nav-bar-transition=android] [nav-bar=entering] .bar,[nav-bar-transition=android] [nav-bar=active] .bar{background:0 0}[nav-bar-transition=android] [nav-bar=cached]{display:block}[nav-bar-transition=android] [nav-bar=cached] .header-item{display:none}[nav-swipe=fast] .back-text,[nav-swipe=fast] .buttons,[nav-swipe=fast] .title,[nav-swipe=fast] [nav-view]{-webkit-transition-duration:50ms;transition-duration:50ms;-webkit-transition-timing-function:linear;transition-timing-function:linear}[nav-swipe=slow] .back-text,[nav-swipe=slow] .buttons,[nav-swipe=slow] .title,[nav-swipe=slow] [nav-view]{-webkit-transition-duration:160ms;transition-duration:160ms;-webkit-transition-timing-function:linear;transition-timing-function:linear}[nav-bar=cached],[nav-view=cached]{display:none}[nav-view=stage]{opacity:0;-webkit-transition-duration:0;transition-duration:0}[nav-bar=stage] .back-text,[nav-bar=stage] .buttons,[nav-bar=stage] .title{position:absolute;opacity:0;-webkit-transition-duration:0s;transition-duration:0s} \ No newline at end of file diff --git a/www/manual_lib/ionic/fonts/ionicons.eot b/www/manual_lib/ionic/fonts/ionicons.eot deleted file mode 100644 index 92a3f20a3..000000000 Binary files a/www/manual_lib/ionic/fonts/ionicons.eot and /dev/null differ diff --git a/www/manual_lib/ionic/fonts/ionicons.svg b/www/manual_lib/ionic/fonts/ionicons.svg deleted file mode 100644 index 49fc8f367..000000000 --- a/www/manual_lib/ionic/fonts/ionicons.svg +++ /dev/null @@ -1,2230 +0,0 @@ - - - - - -Created by FontForge 20120731 at Thu Dec 4 09:51:48 2014 - By Adam Bradley -Created by Adam Bradley with FontForge 2.0 (http://fontforge.sf.net) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/www/manual_lib/ionic/fonts/ionicons.ttf b/www/manual_lib/ionic/fonts/ionicons.ttf deleted file mode 100644 index c4e463248..000000000 Binary files a/www/manual_lib/ionic/fonts/ionicons.ttf and /dev/null differ diff --git a/www/manual_lib/ionic/fonts/ionicons.woff b/www/manual_lib/ionic/fonts/ionicons.woff deleted file mode 100644 index 5f3a14e0a..000000000 Binary files a/www/manual_lib/ionic/fonts/ionicons.woff and /dev/null differ diff --git a/www/manual_lib/ionic/js/ionic-angular.js b/www/manual_lib/ionic/js/ionic-angular.js deleted file mode 100644 index eba49d27f..000000000 --- a/www/manual_lib/ionic/js/ionic-angular.js +++ /dev/null @@ -1,14399 +0,0 @@ -/*! - * Copyright 2015 Drifty Co. - * http://drifty.com/ - * - * Ionic, v1.3.3 - * A powerful HTML5 mobile app framework. - * http://ionicframework.com/ - * - * By @maxlynch, @benjsperry, @adamdbradley <3 - * - * Licensed under the MIT license. Please see LICENSE for more information. - * - */ - -(function() { -/* eslint no-unused-vars:0 */ -var IonicModule = angular.module('ionic', ['ngAnimate', 'ngSanitize', 'ui.router', 'ngIOS9UIWebViewPatch']), - extend = angular.extend, - forEach = angular.forEach, - isDefined = angular.isDefined, - isNumber = angular.isNumber, - isString = angular.isString, - jqLite = angular.element, - noop = angular.noop; - -/** - * @ngdoc service - * @name $ionicActionSheet - * @module ionic - * @description - * The Action Sheet is a slide-up pane that lets the user choose from a set of options. - * Dangerous options are highlighted in red and made obvious. - * - * There are easy ways to cancel out of the action sheet, such as tapping the backdrop or even - * hitting escape on the keyboard for desktop testing. - * - * ![Action Sheet](http://ionicframework.com.s3.amazonaws.com/docs/controllers/actionSheet.gif) - * - * @usage - * To trigger an Action Sheet in your code, use the $ionicActionSheet service in your angular controllers: - * - * ```js - * angular.module('mySuperApp', ['ionic']) - * .controller(function($scope, $ionicActionSheet, $timeout) { - * - * // Triggered on a button click, or some other target - * $scope.show = function() { - * - * // Show the action sheet - * var hideSheet = $ionicActionSheet.show({ - * buttons: [ - * { text: 'Share This' }, - * { text: 'Move' } - * ], - * destructiveText: 'Delete', - * titleText: 'Modify your album', - * cancelText: 'Cancel', - * cancel: function() { - // add cancel code.. - }, - * buttonClicked: function(index) { - * return true; - * } - * }); - * - * // For example's sake, hide the sheet after two seconds - * $timeout(function() { - * hideSheet(); - * }, 2000); - * - * }; - * }); - * ``` - * - */ -IonicModule -.factory('$ionicActionSheet', [ - '$rootScope', - '$compile', - '$animate', - '$timeout', - '$ionicTemplateLoader', - '$ionicPlatform', - '$ionicBody', - 'IONIC_BACK_PRIORITY', -function($rootScope, $compile, $animate, $timeout, $ionicTemplateLoader, $ionicPlatform, $ionicBody, IONIC_BACK_PRIORITY) { - - return { - show: actionSheet - }; - - /** - * @ngdoc method - * @name $ionicActionSheet#show - * @description - * Load and return a new action sheet. - * - * A new isolated scope will be created for the - * action sheet and the new element will be appended into the body. - * - * @param {object} options The options for this ActionSheet. Properties: - * - * - `[Object]` `buttons` Which buttons to show. Each button is an object with a `text` field. - * - `{string}` `titleText` The title to show on the action sheet. - * - `{string=}` `cancelText` the text for a 'cancel' button on the action sheet. - * - `{string=}` `destructiveText` The text for a 'danger' on the action sheet. - * - `{function=}` `cancel` Called if the cancel button is pressed, the backdrop is tapped or - * the hardware back button is pressed. - * - `{function=}` `buttonClicked` Called when one of the non-destructive buttons is clicked, - * with the index of the button that was clicked and the button object. Return true to close - * the action sheet, or false to keep it opened. - * - `{function=}` `destructiveButtonClicked` Called when the destructive button is clicked. - * Return true to close the action sheet, or false to keep it opened. - * - `{boolean=}` `cancelOnStateChange` Whether to cancel the actionSheet when navigating - * to a new state. Default true. - * - `{string}` `cssClass` The custom CSS class name. - * - * @returns {function} `hideSheet` A function which, when called, hides & cancels the action sheet. - */ - function actionSheet(opts) { - var scope = $rootScope.$new(true); - - extend(scope, { - cancel: noop, - destructiveButtonClicked: noop, - buttonClicked: noop, - $deregisterBackButton: noop, - buttons: [], - cancelOnStateChange: true - }, opts || {}); - - function textForIcon(text) { - if (text && /icon/.test(text)) { - scope.$actionSheetHasIcon = true; - } - } - - for (var x = 0; x < scope.buttons.length; x++) { - textForIcon(scope.buttons[x].text); - } - textForIcon(scope.cancelText); - textForIcon(scope.destructiveText); - - // Compile the template - var element = scope.element = $compile('')(scope); - - // Grab the sheet element for animation - var sheetEl = jqLite(element[0].querySelector('.action-sheet-wrapper')); - - var stateChangeListenDone = scope.cancelOnStateChange ? - $rootScope.$on('$stateChangeSuccess', function() { scope.cancel(); }) : - noop; - - // removes the actionSheet from the screen - scope.removeSheet = function(done) { - if (scope.removed) return; - - scope.removed = true; - sheetEl.removeClass('action-sheet-up'); - $timeout(function() { - // wait to remove this due to a 300ms delay native - // click which would trigging whatever was underneath this - $ionicBody.removeClass('action-sheet-open'); - }, 400); - scope.$deregisterBackButton(); - stateChangeListenDone(); - - $animate.removeClass(element, 'active').then(function() { - scope.$destroy(); - element.remove(); - // scope.cancel.$scope is defined near the bottom - scope.cancel.$scope = sheetEl = null; - (done || noop)(opts.buttons); - }); - }; - - scope.showSheet = function(done) { - if (scope.removed) return; - - $ionicBody.append(element) - .addClass('action-sheet-open'); - - $animate.addClass(element, 'active').then(function() { - if (scope.removed) return; - (done || noop)(); - }); - $timeout(function() { - if (scope.removed) return; - sheetEl.addClass('action-sheet-up'); - }, 20, false); - }; - - // registerBackButtonAction returns a callback to deregister the action - scope.$deregisterBackButton = $ionicPlatform.registerBackButtonAction( - function() { - $timeout(scope.cancel); - }, - IONIC_BACK_PRIORITY.actionSheet - ); - - // called when the user presses the cancel button - scope.cancel = function() { - // after the animation is out, call the cancel callback - scope.removeSheet(opts.cancel); - }; - - scope.buttonClicked = function(index) { - // Check if the button click event returned true, which means - // we can close the action sheet - if (opts.buttonClicked(index, opts.buttons[index]) === true) { - scope.removeSheet(); - } - }; - - scope.destructiveButtonClicked = function() { - // Check if the destructive button click event returned true, which means - // we can close the action sheet - if (opts.destructiveButtonClicked() === true) { - scope.removeSheet(); - } - }; - - scope.showSheet(); - - // Expose the scope on $ionicActionSheet's return value for the sake - // of testing it. - scope.cancel.$scope = scope; - - return scope.cancel; - } -}]); - - -jqLite.prototype.addClass = function(cssClasses) { - var x, y, cssClass, el, splitClasses, existingClasses; - if (cssClasses && cssClasses != 'ng-scope' && cssClasses != 'ng-isolate-scope') { - for (x = 0; x < this.length; x++) { - el = this[x]; - if (el.setAttribute) { - - if (cssClasses.indexOf(' ') < 0 && el.classList.add) { - el.classList.add(cssClasses); - } else { - existingClasses = (' ' + (el.getAttribute('class') || '') + ' ') - .replace(/[\n\t]/g, " "); - splitClasses = cssClasses.split(' '); - - for (y = 0; y < splitClasses.length; y++) { - cssClass = splitClasses[y].trim(); - if (existingClasses.indexOf(' ' + cssClass + ' ') === -1) { - existingClasses += cssClass + ' '; - } - } - el.setAttribute('class', existingClasses.trim()); - } - } - } - } - return this; -}; - -jqLite.prototype.removeClass = function(cssClasses) { - var x, y, splitClasses, cssClass, el; - if (cssClasses) { - for (x = 0; x < this.length; x++) { - el = this[x]; - if (el.getAttribute) { - if (cssClasses.indexOf(' ') < 0 && el.classList.remove) { - el.classList.remove(cssClasses); - } else { - splitClasses = cssClasses.split(' '); - - for (y = 0; y < splitClasses.length; y++) { - cssClass = splitClasses[y]; - el.setAttribute('class', ( - (" " + (el.getAttribute('class') || '') + " ") - .replace(/[\n\t]/g, " ") - .replace(" " + cssClass.trim() + " ", " ")).trim() - ); - } - } - } - } - } - return this; -}; - -/** - * @ngdoc service - * @name $ionicBackdrop - * @module ionic - * @description - * Shows and hides a backdrop over the UI. Appears behind popups, loading, - * and other overlays. - * - * Often, multiple UI components require a backdrop, but only one backdrop is - * ever needed in the DOM at a time. - * - * Therefore, each component that requires the backdrop to be shown calls - * `$ionicBackdrop.retain()` when it wants the backdrop, then `$ionicBackdrop.release()` - * when it is done with the backdrop. - * - * For each time `retain` is called, the backdrop will be shown until `release` is called. - * - * For example, if `retain` is called three times, the backdrop will be shown until `release` - * is called three times. - * - * **Notes:** - * - The backdrop service will broadcast 'backdrop.shown' and 'backdrop.hidden' events from the root scope, - * this is useful for alerting native components not in html. - * - * @usage - * - * ```js - * function MyController($scope, $ionicBackdrop, $timeout, $rootScope) { - * //Show a backdrop for one second - * $scope.action = function() { - * $ionicBackdrop.retain(); - * $timeout(function() { - * $ionicBackdrop.release(); - * }, 1000); - * }; - * - * // Execute action on backdrop disappearing - * $scope.$on('backdrop.hidden', function() { - * // Execute action - * }); - * - * // Execute action on backdrop appearing - * $scope.$on('backdrop.shown', function() { - * // Execute action - * }); - * - * } - * ``` - */ -IonicModule -.factory('$ionicBackdrop', [ - '$document', '$timeout', '$$rAF', '$rootScope', -function($document, $timeout, $$rAF, $rootScope) { - - var el = jqLite('
    '); - var backdropHolds = 0; - - $document[0].body.appendChild(el[0]); - - return { - /** - * @ngdoc method - * @name $ionicBackdrop#retain - * @description Retains the backdrop. - */ - retain: retain, - /** - * @ngdoc method - * @name $ionicBackdrop#release - * @description - * Releases the backdrop. - */ - release: release, - - getElement: getElement, - - // exposed for testing - _element: el - }; - - function retain() { - backdropHolds++; - if (backdropHolds === 1) { - el.addClass('visible'); - $rootScope.$broadcast('backdrop.shown'); - $$rAF(function() { - // If we're still at >0 backdropHolds after async... - if (backdropHolds >= 1) el.addClass('active'); - }); - } - } - function release() { - if (backdropHolds === 1) { - el.removeClass('active'); - $rootScope.$broadcast('backdrop.hidden'); - $timeout(function() { - // If we're still at 0 backdropHolds after async... - if (backdropHolds === 0) el.removeClass('visible'); - }, 400, false); - } - backdropHolds = Math.max(0, backdropHolds - 1); - } - - function getElement() { - return el; - } - -}]); - -/** - * @private - */ -IonicModule -.factory('$ionicBind', ['$parse', '$interpolate', function($parse, $interpolate) { - var LOCAL_REGEXP = /^\s*([@=&])(\??)\s*(\w*)\s*$/; - return function(scope, attrs, bindDefinition) { - forEach(bindDefinition || {}, function(definition, scopeName) { - //Adapted from angular.js $compile - var match = definition.match(LOCAL_REGEXP) || [], - attrName = match[3] || scopeName, - mode = match[1], // @, =, or & - parentGet, - unwatch; - - switch (mode) { - case '@': - if (!attrs[attrName]) { - return; - } - attrs.$observe(attrName, function(value) { - scope[scopeName] = value; - }); - // we trigger an interpolation to ensure - // the value is there for use immediately - if (attrs[attrName]) { - scope[scopeName] = $interpolate(attrs[attrName])(scope); - } - break; - - case '=': - if (!attrs[attrName]) { - return; - } - unwatch = scope.$watch(attrs[attrName], function(value) { - scope[scopeName] = value; - }); - //Destroy parent scope watcher when this scope is destroyed - scope.$on('$destroy', unwatch); - break; - - case '&': - /* jshint -W044 */ - if (attrs[attrName] && attrs[attrName].match(RegExp(scopeName + '\(.*?\)'))) { - throw new Error('& expression binding "' + scopeName + '" looks like it will recursively call "' + - attrs[attrName] + '" and cause a stack overflow! Please choose a different scopeName.'); - } - parentGet = $parse(attrs[attrName]); - scope[scopeName] = function(locals) { - return parentGet(scope, locals); - }; - break; - } - }); - }; -}]); - -/** - * @ngdoc service - * @name $ionicBody - * @module ionic - * @description An angular utility service to easily and efficiently - * add and remove CSS classes from the document's body element. - */ -IonicModule -.factory('$ionicBody', ['$document', function($document) { - return { - /** - * @ngdoc method - * @name $ionicBody#addClass - * @description Add a class to the document's body element. - * @param {string} class Each argument will be added to the body element. - * @returns {$ionicBody} The $ionicBody service so methods can be chained. - */ - addClass: function() { - for (var x = 0; x < arguments.length; x++) { - $document[0].body.classList.add(arguments[x]); - } - return this; - }, - /** - * @ngdoc method - * @name $ionicBody#removeClass - * @description Remove a class from the document's body element. - * @param {string} class Each argument will be removed from the body element. - * @returns {$ionicBody} The $ionicBody service so methods can be chained. - */ - removeClass: function() { - for (var x = 0; x < arguments.length; x++) { - $document[0].body.classList.remove(arguments[x]); - } - return this; - }, - /** - * @ngdoc method - * @name $ionicBody#enableClass - * @description Similar to the `add` method, except the first parameter accepts a boolean - * value determining if the class should be added or removed. Rather than writing user code, - * such as "if true then add the class, else then remove the class", this method can be - * given a true or false value which reduces redundant code. - * @param {boolean} shouldEnableClass A true/false value if the class should be added or removed. - * @param {string} class Each remaining argument would be added or removed depending on - * the first argument. - * @returns {$ionicBody} The $ionicBody service so methods can be chained. - */ - enableClass: function(shouldEnableClass) { - var args = Array.prototype.slice.call(arguments).slice(1); - if (shouldEnableClass) { - this.addClass.apply(this, args); - } else { - this.removeClass.apply(this, args); - } - return this; - }, - /** - * @ngdoc method - * @name $ionicBody#append - * @description Append a child to the document's body. - * @param {element} element The element to be appended to the body. The passed in element - * can be either a jqLite element, or a DOM element. - * @returns {$ionicBody} The $ionicBody service so methods can be chained. - */ - append: function(ele) { - $document[0].body.appendChild(ele.length ? ele[0] : ele); - return this; - }, - /** - * @ngdoc method - * @name $ionicBody#get - * @description Get the document's body element. - * @returns {element} Returns the document's body element. - */ - get: function() { - return $document[0].body; - } - }; -}]); - -IonicModule -.factory('$ionicClickBlock', [ - '$document', - '$ionicBody', - '$timeout', -function($document, $ionicBody, $timeout) { - var CSS_HIDE = 'click-block-hide'; - var cbEle, fallbackTimer, pendingShow; - - function preventClick(ev) { - ev.preventDefault(); - ev.stopPropagation(); - } - - function addClickBlock() { - if (pendingShow) { - if (cbEle) { - cbEle.classList.remove(CSS_HIDE); - } else { - cbEle = $document[0].createElement('div'); - cbEle.className = 'click-block'; - $ionicBody.append(cbEle); - cbEle.addEventListener('touchstart', preventClick); - cbEle.addEventListener('mousedown', preventClick); - } - pendingShow = false; - } - } - - function removeClickBlock() { - cbEle && cbEle.classList.add(CSS_HIDE); - } - - return { - show: function(autoExpire) { - pendingShow = true; - $timeout.cancel(fallbackTimer); - fallbackTimer = $timeout(this.hide, autoExpire || 310, false); - addClickBlock(); - }, - hide: function() { - pendingShow = false; - $timeout.cancel(fallbackTimer); - removeClickBlock(); - } - }; -}]); - -/** - * @ngdoc service - * @name $ionicGesture - * @module ionic - * @description An angular service exposing ionic - * {@link ionic.utility:ionic.EventController}'s gestures. - */ -IonicModule -.factory('$ionicGesture', [function() { - return { - /** - * @ngdoc method - * @name $ionicGesture#on - * @description Add an event listener for a gesture on an element. See {@link ionic.utility:ionic.EventController#onGesture}. - * @param {string} eventType The gesture event to listen for. - * @param {function(e)} callback The function to call when the gesture - * happens. - * @param {element} $element The angular element to listen for the event on. - * @param {object} options object. - * @returns {ionic.Gesture} The gesture object (use this to remove the gesture later on). - */ - on: function(eventType, cb, $element, options) { - return window.ionic.onGesture(eventType, cb, $element[0], options); - }, - /** - * @ngdoc method - * @name $ionicGesture#off - * @description Remove an event listener for a gesture on an element. See {@link ionic.utility:ionic.EventController#offGesture}. - * @param {ionic.Gesture} gesture The gesture that should be removed. - * @param {string} eventType The gesture event to remove the listener for. - * @param {function(e)} callback The listener to remove. - */ - off: function(gesture, eventType, cb) { - return window.ionic.offGesture(gesture, eventType, cb); - } - }; -}]); - -/** - * @ngdoc service - * @name $ionicHistory - * @module ionic - * @description - * $ionicHistory keeps track of views as the user navigates through an app. Similar to the way a - * browser behaves, an Ionic app is able to keep track of the previous view, the current view, and - * the forward view (if there is one). However, a typical web browser only keeps track of one - * history stack in a linear fashion. - * - * Unlike a traditional browser environment, apps and webapps have parallel independent histories, - * such as with tabs. Should a user navigate few pages deep on one tab, and then switch to a new - * tab and back, the back button relates not to the previous tab, but to the previous pages - * visited within _that_ tab. - * - * `$ionicHistory` facilitates this parallel history architecture. - */ - -IonicModule -.factory('$ionicHistory', [ - '$rootScope', - '$state', - '$location', - '$window', - '$timeout', - '$ionicViewSwitcher', - '$ionicNavViewDelegate', -function($rootScope, $state, $location, $window, $timeout, $ionicViewSwitcher, $ionicNavViewDelegate) { - - // history actions while navigating views - var ACTION_INITIAL_VIEW = 'initialView'; - var ACTION_NEW_VIEW = 'newView'; - var ACTION_MOVE_BACK = 'moveBack'; - var ACTION_MOVE_FORWARD = 'moveForward'; - - // direction of navigation - var DIRECTION_BACK = 'back'; - var DIRECTION_FORWARD = 'forward'; - var DIRECTION_ENTER = 'enter'; - var DIRECTION_EXIT = 'exit'; - var DIRECTION_SWAP = 'swap'; - var DIRECTION_NONE = 'none'; - - var stateChangeCounter = 0; - var lastStateId, nextViewOptions, deregisterStateChangeListener, nextViewExpireTimer, forcedNav; - - var viewHistory = { - histories: { root: { historyId: 'root', parentHistoryId: null, stack: [], cursor: -1 } }, - views: {}, - backView: null, - forwardView: null, - currentView: null - }; - - var View = function() {}; - View.prototype.initialize = function(data) { - if (data) { - for (var name in data) this[name] = data[name]; - return this; - } - return null; - }; - View.prototype.go = function() { - - if (this.stateName) { - return $state.go(this.stateName, this.stateParams); - } - - if (this.url && this.url !== $location.url()) { - - if (viewHistory.backView === this) { - return $window.history.go(-1); - } else if (viewHistory.forwardView === this) { - return $window.history.go(1); - } - - $location.url(this.url); - } - - return null; - }; - View.prototype.destroy = function() { - if (this.scope) { - this.scope.$destroy && this.scope.$destroy(); - this.scope = null; - } - }; - - - function getViewById(viewId) { - return (viewId ? viewHistory.views[ viewId ] : null); - } - - function getBackView(view) { - return (view ? getViewById(view.backViewId) : null); - } - - function getForwardView(view) { - return (view ? getViewById(view.forwardViewId) : null); - } - - function getHistoryById(historyId) { - return (historyId ? viewHistory.histories[ historyId ] : null); - } - - function getHistory(scope) { - var histObj = getParentHistoryObj(scope); - - if (!viewHistory.histories[ histObj.historyId ]) { - // this history object exists in parent scope, but doesn't - // exist in the history data yet - viewHistory.histories[ histObj.historyId ] = { - historyId: histObj.historyId, - parentHistoryId: getParentHistoryObj(histObj.scope.$parent).historyId, - stack: [], - cursor: -1 - }; - } - return getHistoryById(histObj.historyId); - } - - function getParentHistoryObj(scope) { - var parentScope = scope; - while (parentScope) { - if (parentScope.hasOwnProperty('$historyId')) { - // this parent scope has a historyId - return { historyId: parentScope.$historyId, scope: parentScope }; - } - // nothing found keep climbing up - parentScope = parentScope.$parent; - } - // no history for the parent, use the root - return { historyId: 'root', scope: $rootScope }; - } - - function setNavViews(viewId) { - viewHistory.currentView = getViewById(viewId); - viewHistory.backView = getBackView(viewHistory.currentView); - viewHistory.forwardView = getForwardView(viewHistory.currentView); - } - - function getCurrentStateId() { - var id; - if ($state && $state.current && $state.current.name) { - id = $state.current.name; - if ($state.params) { - for (var key in $state.params) { - if ($state.params.hasOwnProperty(key) && $state.params[key]) { - id += "_" + key + "=" + $state.params[key]; - } - } - } - return id; - } - // if something goes wrong make sure its got a unique stateId - return ionic.Utils.nextUid(); - } - - function getCurrentStateParams() { - var rtn; - if ($state && $state.params) { - for (var key in $state.params) { - if ($state.params.hasOwnProperty(key)) { - rtn = rtn || {}; - rtn[key] = $state.params[key]; - } - } - } - return rtn; - } - - - return { - - register: function(parentScope, viewLocals) { - - var currentStateId = getCurrentStateId(), - hist = getHistory(parentScope), - currentView = viewHistory.currentView, - backView = viewHistory.backView, - forwardView = viewHistory.forwardView, - viewId = null, - action = null, - direction = DIRECTION_NONE, - historyId = hist.historyId, - url = $location.url(), - tmp, x, ele; - - if (lastStateId !== currentStateId) { - lastStateId = currentStateId; - stateChangeCounter++; - } - - if (forcedNav) { - // we've previously set exactly what to do - viewId = forcedNav.viewId; - action = forcedNav.action; - direction = forcedNav.direction; - forcedNav = null; - - } else if (backView && backView.stateId === currentStateId) { - // they went back one, set the old current view as a forward view - viewId = backView.viewId; - historyId = backView.historyId; - action = ACTION_MOVE_BACK; - if (backView.historyId === currentView.historyId) { - // went back in the same history - direction = DIRECTION_BACK; - - } else if (currentView) { - direction = DIRECTION_EXIT; - - tmp = getHistoryById(backView.historyId); - if (tmp && tmp.parentHistoryId === currentView.historyId) { - direction = DIRECTION_ENTER; - - } else { - tmp = getHistoryById(currentView.historyId); - if (tmp && tmp.parentHistoryId === hist.parentHistoryId) { - direction = DIRECTION_SWAP; - } - } - } - - } else if (forwardView && forwardView.stateId === currentStateId) { - // they went to the forward one, set the forward view to no longer a forward view - viewId = forwardView.viewId; - historyId = forwardView.historyId; - action = ACTION_MOVE_FORWARD; - if (forwardView.historyId === currentView.historyId) { - direction = DIRECTION_FORWARD; - - } else if (currentView) { - direction = DIRECTION_EXIT; - - if (currentView.historyId === hist.parentHistoryId) { - direction = DIRECTION_ENTER; - - } else { - tmp = getHistoryById(currentView.historyId); - if (tmp && tmp.parentHistoryId === hist.parentHistoryId) { - direction = DIRECTION_SWAP; - } - } - } - - tmp = getParentHistoryObj(parentScope); - if (forwardView.historyId && tmp.scope) { - // if a history has already been created by the forward view then make sure it stays the same - tmp.scope.$historyId = forwardView.historyId; - historyId = forwardView.historyId; - } - - } else if (currentView && currentView.historyId !== historyId && - hist.cursor > -1 && hist.stack.length > 0 && hist.cursor < hist.stack.length && - hist.stack[hist.cursor].stateId === currentStateId) { - // they just changed to a different history and the history already has views in it - var switchToView = hist.stack[hist.cursor]; - viewId = switchToView.viewId; - historyId = switchToView.historyId; - action = ACTION_MOVE_BACK; - direction = DIRECTION_SWAP; - - tmp = getHistoryById(currentView.historyId); - if (tmp && tmp.parentHistoryId === historyId) { - direction = DIRECTION_EXIT; - - } else { - tmp = getHistoryById(historyId); - if (tmp && tmp.parentHistoryId === currentView.historyId) { - direction = DIRECTION_ENTER; - } - } - - // if switching to a different history, and the history of the view we're switching - // to has an existing back view from a different history than itself, then - // it's back view would be better represented using the current view as its back view - tmp = getViewById(switchToView.backViewId); - if (tmp && switchToView.historyId !== tmp.historyId) { - // the new view is being removed from it's old position in the history and being placed at the top, - // so we need to update any views that reference it as a backview, otherwise there will be infinitely loops - var viewIds = Object.keys(viewHistory.views); - viewIds.forEach(function(viewId) { - var view = viewHistory.views[viewId]; - if ((view.backViewId === switchToView.viewId) && (view.historyId !== switchToView.historyId)) { - view.backViewId = null; - } - }); - - hist.stack[hist.cursor].backViewId = currentView.viewId; - } - - } else { - - // create an element from the viewLocals template - ele = $ionicViewSwitcher.createViewEle(viewLocals); - if (this.isAbstractEle(ele, viewLocals)) { - return { - action: 'abstractView', - direction: DIRECTION_NONE, - ele: ele - }; - } - - // set a new unique viewId - viewId = ionic.Utils.nextUid(); - - if (currentView) { - // set the forward view if there is a current view (ie: if its not the first view) - currentView.forwardViewId = viewId; - - action = ACTION_NEW_VIEW; - - // check if there is a new forward view within the same history - if (forwardView && currentView.stateId !== forwardView.stateId && - currentView.historyId === forwardView.historyId) { - // they navigated to a new view but the stack already has a forward view - // since its a new view remove any forwards that existed - tmp = getHistoryById(forwardView.historyId); - if (tmp) { - // the forward has a history - for (x = tmp.stack.length - 1; x >= forwardView.index; x--) { - // starting from the end destroy all forwards in this history from this point - var stackItem = tmp.stack[x]; - stackItem && stackItem.destroy && stackItem.destroy(); - tmp.stack.splice(x); - } - historyId = forwardView.historyId; - } - } - - // its only moving forward if its in the same history - if (hist.historyId === currentView.historyId) { - direction = DIRECTION_FORWARD; - - } else if (currentView.historyId !== hist.historyId) { - // DB: this is a new view in a different tab - direction = DIRECTION_ENTER; - - tmp = getHistoryById(currentView.historyId); - if (tmp && tmp.parentHistoryId === hist.parentHistoryId) { - direction = DIRECTION_SWAP; - - } else { - tmp = getHistoryById(tmp.parentHistoryId); - if (tmp && tmp.historyId === hist.historyId) { - direction = DIRECTION_EXIT; - } - } - } - - } else { - // there's no current view, so this must be the initial view - action = ACTION_INITIAL_VIEW; - } - - if (stateChangeCounter < 2) { - // views that were spun up on the first load should not animate - direction = DIRECTION_NONE; - } - - // add the new view - viewHistory.views[viewId] = this.createView({ - viewId: viewId, - index: hist.stack.length, - historyId: hist.historyId, - backViewId: (currentView && currentView.viewId ? currentView.viewId : null), - forwardViewId: null, - stateId: currentStateId, - stateName: this.currentStateName(), - stateParams: getCurrentStateParams(), - url: url, - canSwipeBack: canSwipeBack(ele, viewLocals) - }); - - // add the new view to this history's stack - hist.stack.push(viewHistory.views[viewId]); - } - - deregisterStateChangeListener && deregisterStateChangeListener(); - $timeout.cancel(nextViewExpireTimer); - if (nextViewOptions) { - if (nextViewOptions.disableAnimate) direction = DIRECTION_NONE; - if (nextViewOptions.disableBack) viewHistory.views[viewId].backViewId = null; - if (nextViewOptions.historyRoot) { - for (x = 0; x < hist.stack.length; x++) { - if (hist.stack[x].viewId === viewId) { - hist.stack[x].index = 0; - hist.stack[x].backViewId = hist.stack[x].forwardViewId = null; - } else { - delete viewHistory.views[hist.stack[x].viewId]; - } - } - hist.stack = [viewHistory.views[viewId]]; - } - nextViewOptions = null; - } - - setNavViews(viewId); - - if (viewHistory.backView && historyId == viewHistory.backView.historyId && currentStateId == viewHistory.backView.stateId && url == viewHistory.backView.url) { - for (x = 0; x < hist.stack.length; x++) { - if (hist.stack[x].viewId == viewId) { - action = 'dupNav'; - direction = DIRECTION_NONE; - if (x > 0) { - hist.stack[x - 1].forwardViewId = null; - } - viewHistory.forwardView = null; - viewHistory.currentView.index = viewHistory.backView.index; - viewHistory.currentView.backViewId = viewHistory.backView.backViewId; - viewHistory.backView = getBackView(viewHistory.backView); - hist.stack.splice(x, 1); - break; - } - } - } - - hist.cursor = viewHistory.currentView.index; - - return { - viewId: viewId, - action: action, - direction: direction, - historyId: historyId, - enableBack: this.enabledBack(viewHistory.currentView), - isHistoryRoot: (viewHistory.currentView.index === 0), - ele: ele - }; - }, - - registerHistory: function(scope) { - scope.$historyId = ionic.Utils.nextUid(); - }, - - createView: function(data) { - var newView = new View(); - return newView.initialize(data); - }, - - getViewById: getViewById, - - /** - * @ngdoc method - * @name $ionicHistory#viewHistory - * @description The app's view history data, such as all the views and histories, along - * with how they are ordered and linked together within the navigation stack. - * @returns {object} Returns an object containing the apps view history data. - */ - viewHistory: function() { - return viewHistory; - }, - - /** - * @ngdoc method - * @name $ionicHistory#currentView - * @description The app's current view. - * @returns {object} Returns the current view. - */ - currentView: function(view) { - if (arguments.length) { - viewHistory.currentView = view; - } - return viewHistory.currentView; - }, - - /** - * @ngdoc method - * @name $ionicHistory#currentHistoryId - * @description The ID of the history stack which is the parent container of the current view. - * @returns {string} Returns the current history ID. - */ - currentHistoryId: function() { - return viewHistory.currentView ? viewHistory.currentView.historyId : null; - }, - - /** - * @ngdoc method - * @name $ionicHistory#currentTitle - * @description Gets and sets the current view's title. - * @param {string=} val The title to update the current view with. - * @returns {string} Returns the current view's title. - */ - currentTitle: function(val) { - if (viewHistory.currentView) { - if (arguments.length) { - viewHistory.currentView.title = val; - } - return viewHistory.currentView.title; - } - }, - - /** - * @ngdoc method - * @name $ionicHistory#backView - * @description Returns the view that was before the current view in the history stack. - * If the user navigated from View A to View B, then View A would be the back view, and - * View B would be the current view. - * @returns {object} Returns the back view. - */ - backView: function(view) { - if (arguments.length) { - viewHistory.backView = view; - } - return viewHistory.backView; - }, - - /** - * @ngdoc method - * @name $ionicHistory#backTitle - * @description Gets the back view's title. - * @returns {string} Returns the back view's title. - */ - backTitle: function(view) { - var backView = (view && getViewById(view.backViewId)) || viewHistory.backView; - return backView && backView.title; - }, - - /** - * @ngdoc method - * @name $ionicHistory#forwardView - * @description Returns the view that was in front of the current view in the history stack. - * A forward view would exist if the user navigated from View A to View B, then - * navigated back to View A. At this point then View B would be the forward view, and View - * A would be the current view. - * @returns {object} Returns the forward view. - */ - forwardView: function(view) { - if (arguments.length) { - viewHistory.forwardView = view; - } - return viewHistory.forwardView; - }, - - /** - * @ngdoc method - * @name $ionicHistory#currentStateName - * @description Returns the current state name. - * @returns {string} - */ - currentStateName: function() { - return ($state && $state.current ? $state.current.name : null); - }, - - isCurrentStateNavView: function(navView) { - return !!($state && $state.current && $state.current.views && $state.current.views[navView]); - }, - - goToHistoryRoot: function(historyId) { - if (historyId) { - var hist = getHistoryById(historyId); - if (hist && hist.stack.length) { - if (viewHistory.currentView && viewHistory.currentView.viewId === hist.stack[0].viewId) { - return; - } - forcedNav = { - viewId: hist.stack[0].viewId, - action: ACTION_MOVE_BACK, - direction: DIRECTION_BACK - }; - hist.stack[0].go(); - } - } - }, - - /** - * @ngdoc method - * @name $ionicHistory#goBack - * @param {number=} backCount Optional negative integer setting how many views to go - * back. By default it'll go back one view by using the value `-1`. To go back two - * views you would use `-2`. If the number goes farther back than the number of views - * in the current history's stack then it'll go to the first view in the current history's - * stack. If the number is zero or greater then it'll do nothing. It also does not - * cross history stacks, meaning it can only go as far back as the current history. - * @description Navigates the app to the back view, if a back view exists. - */ - goBack: function(backCount) { - if (isDefined(backCount) && backCount !== -1) { - if (backCount > -1) return; - - var currentHistory = viewHistory.histories[this.currentHistoryId()]; - var newCursor = currentHistory.cursor + backCount + 1; - if (newCursor < 1) { - newCursor = 1; - } - - currentHistory.cursor = newCursor; - setNavViews(currentHistory.stack[newCursor].viewId); - - var cursor = newCursor - 1; - var clearStateIds = []; - var fwdView = getViewById(currentHistory.stack[cursor].forwardViewId); - while (fwdView) { - clearStateIds.push(fwdView.stateId || fwdView.viewId); - cursor++; - if (cursor >= currentHistory.stack.length) break; - fwdView = getViewById(currentHistory.stack[cursor].forwardViewId); - } - - var self = this; - if (clearStateIds.length) { - $timeout(function() { - self.clearCache(clearStateIds); - }, 300); - } - } - - viewHistory.backView && viewHistory.backView.go(); - }, - - /** - * @ngdoc method - * @name $ionicHistory#removeBackView - * @description Remove the previous view from the history completely, including the - * cached element and scope (if they exist). - */ - removeBackView: function() { - var self = this; - var currentHistory = viewHistory.histories[this.currentHistoryId()]; - var currentCursor = currentHistory.cursor; - - var currentView = currentHistory.stack[currentCursor]; - var backView = currentHistory.stack[currentCursor - 1]; - var replacementView = currentHistory.stack[currentCursor - 2]; - - // fail if we dont have enough views in the history - if (!backView || !replacementView) { - return; - } - - // remove the old backView and the cached element/scope - currentHistory.stack.splice(currentCursor - 1, 1); - self.clearCache([backView.viewId]); - // make the replacementView and currentView point to each other (bypass the old backView) - currentView.backViewId = replacementView.viewId; - currentView.index = currentView.index - 1; - replacementView.forwardViewId = currentView.viewId; - // update the cursor and set new backView - viewHistory.backView = replacementView; - currentHistory.currentCursor += -1; - }, - - enabledBack: function(view) { - var backView = getBackView(view); - return !!(backView && backView.historyId === view.historyId); - }, - - /** - * @ngdoc method - * @name $ionicHistory#clearHistory - * @description Clears out the app's entire history, except for the current view. - */ - clearHistory: function() { - var - histories = viewHistory.histories, - currentView = viewHistory.currentView; - - if (histories) { - for (var historyId in histories) { - - if (histories[historyId].stack) { - histories[historyId].stack = []; - histories[historyId].cursor = -1; - } - - if (currentView && currentView.historyId === historyId) { - currentView.backViewId = currentView.forwardViewId = null; - histories[historyId].stack.push(currentView); - } else if (histories[historyId].destroy) { - histories[historyId].destroy(); - } - - } - } - - for (var viewId in viewHistory.views) { - if (viewId !== currentView.viewId) { - delete viewHistory.views[viewId]; - } - } - - if (currentView) { - setNavViews(currentView.viewId); - } - }, - - /** - * @ngdoc method - * @name $ionicHistory#clearCache - * @return promise - * @description Removes all cached views within every {@link ionic.directive:ionNavView}. - * This both removes the view element from the DOM, and destroy it's scope. - */ - clearCache: function(stateIds) { - return $timeout(function() { - $ionicNavViewDelegate._instances.forEach(function(instance) { - instance.clearCache(stateIds); - }); - }); - }, - - /** - * @ngdoc method - * @name $ionicHistory#nextViewOptions - * @description Sets options for the next view. This method can be useful to override - * certain view/transition defaults right before a view transition happens. For example, - * the {@link ionic.directive:menuClose} directive uses this method internally to ensure - * an animated view transition does not happen when a side menu is open, and also sets - * the next view as the root of its history stack. After the transition these options - * are set back to null. - * - * Available options: - * - * * `disableAnimate`: Do not animate the next transition. - * * `disableBack`: The next view should forget its back view, and set it to null. - * * `historyRoot`: The next view should become the root view in its history stack. - * - * ```js - * $ionicHistory.nextViewOptions({ - * disableAnimate: true, - * disableBack: true - * }); - * ``` - */ - nextViewOptions: function(opts) { - deregisterStateChangeListener && deregisterStateChangeListener(); - if (arguments.length) { - $timeout.cancel(nextViewExpireTimer); - if (opts === null) { - nextViewOptions = opts; - } else { - nextViewOptions = nextViewOptions || {}; - extend(nextViewOptions, opts); - if (nextViewOptions.expire) { - deregisterStateChangeListener = $rootScope.$on('$stateChangeSuccess', function() { - nextViewExpireTimer = $timeout(function() { - nextViewOptions = null; - }, nextViewOptions.expire); - }); - } - } - } - return nextViewOptions; - }, - - isAbstractEle: function(ele, viewLocals) { - if (viewLocals && viewLocals.$$state && viewLocals.$$state.self['abstract']) { - return true; - } - return !!(ele && (isAbstractTag(ele) || isAbstractTag(ele.children()))); - }, - - isActiveScope: function(scope) { - if (!scope) return false; - - var climbScope = scope; - var currentHistoryId = this.currentHistoryId(); - var foundHistoryId; - - while (climbScope) { - if (climbScope.$$disconnected) { - return false; - } - - if (!foundHistoryId && climbScope.hasOwnProperty('$historyId')) { - foundHistoryId = true; - } - - if (currentHistoryId) { - if (climbScope.hasOwnProperty('$historyId') && currentHistoryId == climbScope.$historyId) { - return true; - } - if (climbScope.hasOwnProperty('$activeHistoryId')) { - if (currentHistoryId == climbScope.$activeHistoryId) { - if (climbScope.hasOwnProperty('$historyId')) { - return true; - } - if (!foundHistoryId) { - return true; - } - } - } - } - - if (foundHistoryId && climbScope.hasOwnProperty('$activeHistoryId')) { - foundHistoryId = false; - } - - climbScope = climbScope.$parent; - } - - return currentHistoryId ? currentHistoryId == 'root' : true; - } - - }; - - function isAbstractTag(ele) { - return ele && ele.length && /ion-side-menus|ion-tabs/i.test(ele[0].tagName); - } - - function canSwipeBack(ele, viewLocals) { - if (viewLocals && viewLocals.$$state && viewLocals.$$state.self.canSwipeBack === false) { - return false; - } - if (ele && ele.attr('can-swipe-back') === 'false') { - return false; - } - var eleChild = ele.find('ion-view'); - if (eleChild && eleChild.attr('can-swipe-back') === 'false') { - return false; - } - return true; - } - -}]) - -.run([ - '$rootScope', - '$state', - '$location', - '$document', - '$ionicPlatform', - '$ionicHistory', - 'IONIC_BACK_PRIORITY', -function($rootScope, $state, $location, $document, $ionicPlatform, $ionicHistory, IONIC_BACK_PRIORITY) { - - // always reset the keyboard state when change stage - $rootScope.$on('$ionicView.beforeEnter', function() { - ionic.keyboard && ionic.keyboard.hide && ionic.keyboard.hide(); - }); - - $rootScope.$on('$ionicHistory.change', function(e, data) { - if (!data) return null; - - var viewHistory = $ionicHistory.viewHistory(); - - var hist = (data.historyId ? viewHistory.histories[ data.historyId ] : null); - if (hist && hist.cursor > -1 && hist.cursor < hist.stack.length) { - // the history they're going to already exists - // go to it's last view in its stack - var view = hist.stack[ hist.cursor ]; - return view.go(data); - } - - // this history does not have a URL, but it does have a uiSref - // figure out its URL from the uiSref - if (!data.url && data.uiSref) { - data.url = $state.href(data.uiSref); - } - - if (data.url) { - // don't let it start with a #, messes with $location.url() - if (data.url.indexOf('#') === 0) { - data.url = data.url.replace('#', ''); - } - if (data.url !== $location.url()) { - // we've got a good URL, ready GO! - $location.url(data.url); - } - } - }); - - $rootScope.$ionicGoBack = function(backCount) { - $ionicHistory.goBack(backCount); - }; - - // Set the document title when a new view is shown - $rootScope.$on('$ionicView.afterEnter', function(ev, data) { - if (data && data.title) { - $document[0].title = data.title; - } - }); - - // Triggered when devices with a hardware back button (Android) is clicked by the user - // This is a Cordova/Phonegap platform specifc method - function onHardwareBackButton(e) { - var backView = $ionicHistory.backView(); - if (backView) { - // there is a back view, go to it - backView.go(); - } else { - // there is no back view, so close the app instead - ionic.Platform.exitApp(); - } - e.preventDefault(); - return false; - } - $ionicPlatform.registerBackButtonAction( - onHardwareBackButton, - IONIC_BACK_PRIORITY.view - ); - -}]); - -/** - * @ngdoc provider - * @name $ionicConfigProvider - * @module ionic - * @description - * Ionic automatically takes platform configurations into account to adjust things like what - * transition style to use and whether tab icons should show on the top or bottom. For example, - * iOS will move forward by transitioning the entering view from right to center and the leaving - * view from center to left. However, Android will transition with the entering view going from - * bottom to center, covering the previous view, which remains stationary. It should be noted - * that when a platform is not iOS or Android, then it'll default to iOS. So if you are - * developing on a desktop browser, it's going to take on iOS default configs. - * - * These configs can be changed using the `$ionicConfigProvider` during the configuration phase - * of your app. Additionally, `$ionicConfig` can also set and get config values during the run - * phase and within the app itself. - * - * By default, all base config variables are set to `'platform'`, which means it'll take on the - * default config of the platform on which it's running. Config variables can be set at this - * level so all platforms follow the same setting, rather than its platform config. - * The following code would set the same config variable for all platforms: - * - * ```js - * $ionicConfigProvider.views.maxCache(10); - * ``` - * - * Additionally, each platform can have its own config within the `$ionicConfigProvider.platform` - * property. The config below would only apply to Android devices. - * - * ```js - * $ionicConfigProvider.platform.android.views.maxCache(5); - * ``` - * - * @usage - * ```js - * var myApp = angular.module('reallyCoolApp', ['ionic']); - * - * myApp.config(function($ionicConfigProvider) { - * $ionicConfigProvider.views.maxCache(5); - * - * // note that you can also chain configs - * $ionicConfigProvider.backButton.text('Go Back').icon('ion-chevron-left'); - * }); - * ``` - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#views.transition - * @description Animation style when transitioning between views. Default `platform`. - * - * @param {string} transition Which style of view transitioning to use. - * - * * `platform`: Dynamically choose the correct transition style depending on the platform - * the app is running from. If the platform is not `ios` or `android` then it will default - * to `ios`. - * * `ios`: iOS style transition. - * * `android`: Android style transition. - * * `none`: Do not perform animated transitions. - * - * @returns {string} value - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#views.maxCache - * @description Maximum number of view elements to cache in the DOM. When the max number is - * exceeded, the view with the longest time period since it was accessed is removed. Views that - * stay in the DOM cache the view's scope, current state, and scroll position. The scope is - * disconnected from the `$watch` cycle when it is cached and reconnected when it enters again. - * When the maximum cache is `0`, the leaving view's element will be removed from the DOM after - * each view transition, and the next time the same view is shown, it will have to re-compile, - * attach to the DOM, and link the element again. This disables caching, in effect. - * @param {number} maxNumber Maximum number of views to retain. Default `10`. - * @returns {number} How many views Ionic will hold onto until the a view is removed. - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#views.forwardCache - * @description By default, when navigating, views that were recently visited are cached, and - * the same instance data and DOM elements are referenced when navigating back. However, when - * navigating back in the history, the "forward" views are removed from the cache. If you - * navigate forward to the same view again, it'll create a new DOM element and controller - * instance. Basically, any forward views are reset each time. Set this config to `true` to have - * forward views cached and not reset on each load. - * @param {boolean} value - * @returns {boolean} - */ - - /** - * @ngdoc method - * @name $ionicConfigProvider#views.swipeBackEnabled - * @description By default on iOS devices, swipe to go back functionality is enabled by default. - * This method can be used to disable it globally, or on a per-view basis. - * Note: This functionality is only supported on iOS. - * @param {boolean} value - * @returns {boolean} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#scrolling.jsScrolling - * @description Whether to use JS or Native scrolling. Defaults to native scrolling. Setting this to - * `true` has the same effect as setting each `ion-content` to have `overflow-scroll='false'`. - * @param {boolean} value Defaults to `false` as of Ionic 1.2 - * @returns {boolean} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#backButton.icon - * @description Back button icon. - * @param {string} value - * @returns {string} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#backButton.text - * @description Back button text. - * @param {string} value Defaults to `Back`. - * @returns {string} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#backButton.previousTitleText - * @description If the previous title text should become the back button text. This - * is the default for iOS. - * @param {boolean} value - * @returns {boolean} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#form.checkbox - * @description Checkbox style. Android defaults to `square` and iOS defaults to `circle`. - * @param {string} value - * @returns {string} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#form.toggle - * @description Toggle item style. Android defaults to `small` and iOS defaults to `large`. - * @param {string} value - * @returns {string} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#spinner.icon - * @description Default spinner icon to use. - * @param {string} value Can be: `android`, `ios`, `ios-small`, `bubbles`, `circles`, `crescent`, - * `dots`, `lines`, `ripple`, or `spiral`. - * @returns {string} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#tabs.style - * @description Tab style. Android defaults to `striped` and iOS defaults to `standard`. - * @param {string} value Available values include `striped` and `standard`. - * @returns {string} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#tabs.position - * @description Tab position. Android defaults to `top` and iOS defaults to `bottom`. - * @param {string} value Available values include `top` and `bottom`. - * @returns {string} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#templates.maxPrefetch - * @description Sets the maximum number of templates to prefetch from the templateUrls defined in - * $stateProvider.state. If set to `0`, the user will have to wait - * for a template to be fetched the first time when navigating to a new page. Default `30`. - * @param {integer} value Max number of template to prefetch from the templateUrls defined in - * `$stateProvider.state()`. - * @returns {integer} - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#navBar.alignTitle - * @description Which side of the navBar to align the title. Default `center`. - * - * @param {string} value side of the navBar to align the title. - * - * * `platform`: Dynamically choose the correct title style depending on the platform - * the app is running from. If the platform is `ios`, it will default to `center`. - * If the platform is `android`, it will default to `left`. If the platform is not - * `ios` or `android`, it will default to `center`. - * - * * `left`: Left align the title in the navBar - * * `center`: Center align the title in the navBar - * * `right`: Right align the title in the navBar. - * - * @returns {string} value - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#navBar.positionPrimaryButtons - * @description Which side of the navBar to align the primary navBar buttons. Default `left`. - * - * @param {string} value side of the navBar to align the primary navBar buttons. - * - * * `platform`: Dynamically choose the correct title style depending on the platform - * the app is running from. If the platform is `ios`, it will default to `left`. - * If the platform is `android`, it will default to `right`. If the platform is not - * `ios` or `android`, it will default to `left`. - * - * * `left`: Left align the primary navBar buttons in the navBar - * * `right`: Right align the primary navBar buttons in the navBar. - * - * @returns {string} value - */ - -/** - * @ngdoc method - * @name $ionicConfigProvider#navBar.positionSecondaryButtons - * @description Which side of the navBar to align the secondary navBar buttons. Default `right`. - * - * @param {string} value side of the navBar to align the secondary navBar buttons. - * - * * `platform`: Dynamically choose the correct title style depending on the platform - * the app is running from. If the platform is `ios`, it will default to `right`. - * If the platform is `android`, it will default to `right`. If the platform is not - * `ios` or `android`, it will default to `right`. - * - * * `left`: Left align the secondary navBar buttons in the navBar - * * `right`: Right align the secondary navBar buttons in the navBar. - * - * @returns {string} value - */ - -IonicModule -.provider('$ionicConfig', function() { - - var provider = this; - provider.platform = {}; - var PLATFORM = 'platform'; - - var configProperties = { - views: { - maxCache: PLATFORM, - forwardCache: PLATFORM, - transition: PLATFORM, - swipeBackEnabled: PLATFORM, - swipeBackHitWidth: PLATFORM - }, - navBar: { - alignTitle: PLATFORM, - positionPrimaryButtons: PLATFORM, - positionSecondaryButtons: PLATFORM, - transition: PLATFORM - }, - backButton: { - icon: PLATFORM, - text: PLATFORM, - previousTitleText: PLATFORM - }, - form: { - checkbox: PLATFORM, - toggle: PLATFORM - }, - scrolling: { - jsScrolling: PLATFORM - }, - spinner: { - icon: PLATFORM - }, - tabs: { - style: PLATFORM, - position: PLATFORM - }, - templates: { - maxPrefetch: PLATFORM - }, - platform: {} - }; - createConfig(configProperties, provider, ''); - - - - // Default - // ------------------------- - setPlatformConfig('default', { - - views: { - maxCache: 10, - forwardCache: false, - transition: 'ios', - swipeBackEnabled: true, - swipeBackHitWidth: 45 - }, - - navBar: { - alignTitle: 'center', - positionPrimaryButtons: 'left', - positionSecondaryButtons: 'right', - transition: 'view' - }, - - backButton: { - icon: 'ion-ios-arrow-back', - text: 'Back', - previousTitleText: true - }, - - form: { - checkbox: 'circle', - toggle: 'large' - }, - - scrolling: { - jsScrolling: true - }, - - spinner: { - icon: 'ios' - }, - - tabs: { - style: 'standard', - position: 'bottom' - }, - - templates: { - maxPrefetch: 30 - } - - }); - - - - // iOS (it is the default already) - // ------------------------- - setPlatformConfig('ios', {}); - - - - // Android - // ------------------------- - setPlatformConfig('android', { - - views: { - transition: 'android', - swipeBackEnabled: false - }, - - navBar: { - alignTitle: 'left', - positionPrimaryButtons: 'right', - positionSecondaryButtons: 'right' - }, - - backButton: { - icon: 'ion-android-arrow-back', - text: false, - previousTitleText: false - }, - - form: { - checkbox: 'square', - toggle: 'small' - }, - - spinner: { - icon: 'android' - }, - - tabs: { - style: 'striped', - position: 'top' - }, - - scrolling: { - jsScrolling: false - } - }); - - // Windows Phone - // ------------------------- - setPlatformConfig('windowsphone', { - //scrolling: { - // jsScrolling: false - //} - spinner: { - icon: 'android' - } - }); - - - provider.transitions = { - views: {}, - navBar: {} - }; - - - // iOS Transitions - // ----------------------- - provider.transitions.views.ios = function(enteringEle, leavingEle, direction, shouldAnimate) { - - function setStyles(ele, opacity, x, boxShadowOpacity) { - var css = {}; - css[ionic.CSS.TRANSITION_DURATION] = d.shouldAnimate ? '' : 0; - css.opacity = opacity; - if (boxShadowOpacity > -1) { - css.boxShadow = '0 0 10px rgba(0,0,0,' + (d.shouldAnimate ? boxShadowOpacity * 0.45 : 0.3) + ')'; - } - css[ionic.CSS.TRANSFORM] = 'translate3d(' + x + '%,0,0)'; - ionic.DomUtil.cachedStyles(ele, css); - } - - var d = { - run: function(step) { - if (direction == 'forward') { - setStyles(enteringEle, 1, (1 - step) * 99, 1 - step); // starting at 98% prevents a flicker - setStyles(leavingEle, (1 - 0.1 * step), step * -33, -1); - - } else if (direction == 'back') { - setStyles(enteringEle, (1 - 0.1 * (1 - step)), (1 - step) * -33, -1); - setStyles(leavingEle, 1, step * 100, 1 - step); - - } else { - // swap, enter, exit - setStyles(enteringEle, 1, 0, -1); - setStyles(leavingEle, 0, 0, -1); - } - }, - shouldAnimate: shouldAnimate && (direction == 'forward' || direction == 'back') - }; - - return d; - }; - - provider.transitions.navBar.ios = function(enteringHeaderBar, leavingHeaderBar, direction, shouldAnimate) { - - function setStyles(ctrl, opacity, titleX, backTextX) { - var css = {}; - css[ionic.CSS.TRANSITION_DURATION] = d.shouldAnimate ? '' : '0ms'; - css.opacity = opacity === 1 ? '' : opacity; - - ctrl.setCss('buttons-left', css); - ctrl.setCss('buttons-right', css); - ctrl.setCss('back-button', css); - - css[ionic.CSS.TRANSFORM] = 'translate3d(' + backTextX + 'px,0,0)'; - ctrl.setCss('back-text', css); - - css[ionic.CSS.TRANSFORM] = 'translate3d(' + titleX + 'px,0,0)'; - ctrl.setCss('title', css); - } - - function enter(ctrlA, ctrlB, step) { - if (!ctrlA || !ctrlB) return; - var titleX = (ctrlA.titleTextX() + ctrlA.titleWidth()) * (1 - step); - var backTextX = (ctrlB && (ctrlB.titleTextX() - ctrlA.backButtonTextLeft()) * (1 - step)) || 0; - setStyles(ctrlA, step, titleX, backTextX); - } - - function leave(ctrlA, ctrlB, step) { - if (!ctrlA || !ctrlB) return; - var titleX = (-(ctrlA.titleTextX() - ctrlB.backButtonTextLeft()) - (ctrlA.titleLeftRight())) * step; - setStyles(ctrlA, 1 - step, titleX, 0); - } - - var d = { - run: function(step) { - var enteringHeaderCtrl = enteringHeaderBar.controller(); - var leavingHeaderCtrl = leavingHeaderBar && leavingHeaderBar.controller(); - if (d.direction == 'back') { - leave(enteringHeaderCtrl, leavingHeaderCtrl, 1 - step); - enter(leavingHeaderCtrl, enteringHeaderCtrl, 1 - step); - } else { - enter(enteringHeaderCtrl, leavingHeaderCtrl, step); - leave(leavingHeaderCtrl, enteringHeaderCtrl, step); - } - }, - direction: direction, - shouldAnimate: shouldAnimate && (direction == 'forward' || direction == 'back') - }; - - return d; - }; - - - // Android Transitions - // ----------------------- - - provider.transitions.views.android = function(enteringEle, leavingEle, direction, shouldAnimate) { - shouldAnimate = shouldAnimate && (direction == 'forward' || direction == 'back'); - - function setStyles(ele, x, opacity) { - var css = {}; - css[ionic.CSS.TRANSITION_DURATION] = d.shouldAnimate ? '' : 0; - css[ionic.CSS.TRANSFORM] = 'translate3d(' + x + '%,0,0)'; - css.opacity = opacity; - ionic.DomUtil.cachedStyles(ele, css); - } - - var d = { - run: function(step) { - if (direction == 'forward') { - setStyles(enteringEle, (1 - step) * 99, 1); // starting at 98% prevents a flicker - setStyles(leavingEle, step * -100, 1); - - } else if (direction == 'back') { - setStyles(enteringEle, (1 - step) * -100, 1); - setStyles(leavingEle, step * 100, 1); - - } else { - // swap, enter, exit - setStyles(enteringEle, 0, 1); - setStyles(leavingEle, 0, 0); - } - }, - shouldAnimate: shouldAnimate - }; - - return d; - }; - - provider.transitions.navBar.android = function(enteringHeaderBar, leavingHeaderBar, direction, shouldAnimate) { - - function setStyles(ctrl, opacity) { - if (!ctrl) return; - var css = {}; - css.opacity = opacity === 1 ? '' : opacity; - - ctrl.setCss('buttons-left', css); - ctrl.setCss('buttons-right', css); - ctrl.setCss('back-button', css); - ctrl.setCss('back-text', css); - ctrl.setCss('title', css); - } - - return { - run: function(step) { - setStyles(enteringHeaderBar.controller(), step); - setStyles(leavingHeaderBar && leavingHeaderBar.controller(), 1 - step); - }, - shouldAnimate: shouldAnimate && (direction == 'forward' || direction == 'back') - }; - }; - - - // No Transition - // ----------------------- - - provider.transitions.views.none = function(enteringEle, leavingEle) { - return { - run: function(step) { - provider.transitions.views.android(enteringEle, leavingEle, false, false).run(step); - }, - shouldAnimate: false - }; - }; - - provider.transitions.navBar.none = function(enteringHeaderBar, leavingHeaderBar) { - return { - run: function(step) { - provider.transitions.navBar.ios(enteringHeaderBar, leavingHeaderBar, false, false).run(step); - provider.transitions.navBar.android(enteringHeaderBar, leavingHeaderBar, false, false).run(step); - }, - shouldAnimate: false - }; - }; - - - // private: used to set platform configs - function setPlatformConfig(platformName, platformConfigs) { - configProperties.platform[platformName] = platformConfigs; - provider.platform[platformName] = {}; - - addConfig(configProperties, configProperties.platform[platformName]); - - createConfig(configProperties.platform[platformName], provider.platform[platformName], ''); - } - - - // private: used to recursively add new platform configs - function addConfig(configObj, platformObj) { - for (var n in configObj) { - if (n != PLATFORM && configObj.hasOwnProperty(n)) { - if (angular.isObject(configObj[n])) { - if (!isDefined(platformObj[n])) { - platformObj[n] = {}; - } - addConfig(configObj[n], platformObj[n]); - - } else if (!isDefined(platformObj[n])) { - platformObj[n] = null; - } - } - } - } - - - // private: create methods for each config to get/set - function createConfig(configObj, providerObj, platformPath) { - forEach(configObj, function(value, namespace) { - - if (angular.isObject(configObj[namespace])) { - // recursively drill down the config object so we can create a method for each one - providerObj[namespace] = {}; - createConfig(configObj[namespace], providerObj[namespace], platformPath + '.' + namespace); - - } else { - // create a method for the provider/config methods that will be exposed - providerObj[namespace] = function(newValue) { - if (arguments.length) { - configObj[namespace] = newValue; - return providerObj; - } - if (configObj[namespace] == PLATFORM) { - // if the config is set to 'platform', then get this config's platform value - var platformConfig = stringObj(configProperties.platform, ionic.Platform.platform() + platformPath + '.' + namespace); - if (platformConfig || platformConfig === false) { - return platformConfig; - } - // didnt find a specific platform config, now try the default - return stringObj(configProperties.platform, 'default' + platformPath + '.' + namespace); - } - return configObj[namespace]; - }; - } - - }); - } - - function stringObj(obj, str) { - str = str.split("."); - for (var i = 0; i < str.length; i++) { - if (obj && isDefined(obj[str[i]])) { - obj = obj[str[i]]; - } else { - return null; - } - } - return obj; - } - - provider.setPlatformConfig = setPlatformConfig; - - - // private: Service definition for internal Ionic use - /** - * @ngdoc service - * @name $ionicConfig - * @module ionic - * @private - */ - provider.$get = function() { - return provider; - }; -}) -// Fix for URLs in Cordova apps on Windows Phone -// http://blogs.msdn.com/b/msdn_answers/archive/2015/02/10/ -// running-cordova-apps-on-windows-and-windows-phone-8-1-using-ionic-angularjs-and-other-frameworks.aspx -.config(['$compileProvider', function($compileProvider) { - $compileProvider.aHrefSanitizationWhitelist(/^\s*(https?|sms|tel|geo|ftp|mailto|file|ghttps?|ms-appx-web|ms-appx|x-wmapp0):/); - $compileProvider.imgSrcSanitizationWhitelist(/^\s*(https?|ftp|file|content|blob|ms-appx|ms-appx-web|x-wmapp0):|data:image\//); -}]); - - -var LOADING_TPL = - '
    ' + - '
    ' + - '
    ' + - '
    '; - -/** - * @ngdoc service - * @name $ionicLoading - * @module ionic - * @description - * An overlay that can be used to indicate activity while blocking user - * interaction. - * - * @usage - * ```js - * angular.module('LoadingApp', ['ionic']) - * .controller('LoadingCtrl', function($scope, $ionicLoading) { - * $scope.show = function() { - * $ionicLoading.show({ - * template: 'Loading...', - * duration: 3000 - * }).then(function(){ - * console.log("The loading indicator is now displayed"); - * }); - * }; - * $scope.hide = function(){ - * $ionicLoading.hide().then(function(){ - * console.log("The loading indicator is now hidden"); - * }); - * }; - * }); - * ``` - */ -/** - * @ngdoc object - * @name $ionicLoadingConfig - * @module ionic - * @description - * Set the default options to be passed to the {@link ionic.service:$ionicLoading} service. - * - * @usage - * ```js - * var app = angular.module('myApp', ['ionic']) - * app.constant('$ionicLoadingConfig', { - * template: 'Default Loading Template...' - * }); - * app.controller('AppCtrl', function($scope, $ionicLoading) { - * $scope.showLoading = function() { - * //options default to values in $ionicLoadingConfig - * $ionicLoading.show().then(function(){ - * console.log("The loading indicator is now displayed"); - * }); - * }; - * }); - * ``` - */ -IonicModule -.constant('$ionicLoadingConfig', { - template: '' -}) -.factory('$ionicLoading', [ - '$ionicLoadingConfig', - '$ionicBody', - '$ionicTemplateLoader', - '$ionicBackdrop', - '$timeout', - '$q', - '$log', - '$compile', - '$ionicPlatform', - '$rootScope', - 'IONIC_BACK_PRIORITY', -function($ionicLoadingConfig, $ionicBody, $ionicTemplateLoader, $ionicBackdrop, $timeout, $q, $log, $compile, $ionicPlatform, $rootScope, IONIC_BACK_PRIORITY) { - - var loaderInstance; - //default values - var deregisterBackAction = noop; - var deregisterStateListener1 = noop; - var deregisterStateListener2 = noop; - var loadingShowDelay = $q.when(); - - return { - /** - * @ngdoc method - * @name $ionicLoading#show - * @description Shows a loading indicator. If the indicator is already shown, - * it will set the options given and keep the indicator shown. - * @returns {promise} A promise which is resolved when the loading indicator is presented. - * @param {object} opts The options for the loading indicator. Available properties: - * - `{string=}` `template` The html content of the indicator. - * - `{string=}` `templateUrl` The url of an html template to load as the content of the indicator. - * - `{object=}` `scope` The scope to be a child of. Default: creates a child of $rootScope. - * - `{boolean=}` `noBackdrop` Whether to hide the backdrop. By default it will be shown. - * - `{boolean=}` `hideOnStateChange` Whether to hide the loading spinner when navigating - * to a new state. Default false. - * - `{number=}` `delay` How many milliseconds to delay showing the indicator. By default there is no delay. - * - `{number=}` `duration` How many milliseconds to wait until automatically - * hiding the indicator. By default, the indicator will be shown until `.hide()` is called. - */ - show: showLoader, - /** - * @ngdoc method - * @name $ionicLoading#hide - * @description Hides the loading indicator, if shown. - * @returns {promise} A promise which is resolved when the loading indicator is hidden. - */ - hide: hideLoader, - /** - * @private for testing - */ - _getLoader: getLoader - }; - - function getLoader() { - if (!loaderInstance) { - loaderInstance = $ionicTemplateLoader.compile({ - template: LOADING_TPL, - appendTo: $ionicBody.get() - }) - .then(function(self) { - self.show = function(options) { - var templatePromise = options.templateUrl ? - $ionicTemplateLoader.load(options.templateUrl) : - //options.content: deprecated - $q.when(options.template || options.content || ''); - - self.scope = options.scope || self.scope; - - if (!self.isShown) { - //options.showBackdrop: deprecated - self.hasBackdrop = !options.noBackdrop && options.showBackdrop !== false; - if (self.hasBackdrop) { - $ionicBackdrop.retain(); - $ionicBackdrop.getElement().addClass('backdrop-loading'); - } - } - - if (options.duration) { - $timeout.cancel(self.durationTimeout); - self.durationTimeout = $timeout( - angular.bind(self, self.hide), - +options.duration - ); - } - - deregisterBackAction(); - //Disable hardware back button while loading - deregisterBackAction = $ionicPlatform.registerBackButtonAction( - noop, - IONIC_BACK_PRIORITY.loading - ); - - templatePromise.then(function(html) { - if (html) { - var loading = self.element.children(); - loading.html(html); - $compile(loading.contents())(self.scope); - } - - //Don't show until template changes - if (self.isShown) { - self.element.addClass('visible'); - ionic.requestAnimationFrame(function() { - if (self.isShown) { - self.element.addClass('active'); - $ionicBody.addClass('loading-active'); - } - }); - } - }); - - self.isShown = true; - }; - self.hide = function() { - - deregisterBackAction(); - if (self.isShown) { - if (self.hasBackdrop) { - $ionicBackdrop.release(); - $ionicBackdrop.getElement().removeClass('backdrop-loading'); - } - self.element.removeClass('active'); - $ionicBody.removeClass('loading-active'); - self.element.removeClass('visible'); - ionic.requestAnimationFrame(function() { - !self.isShown && self.element.removeClass('visible'); - }); - } - $timeout.cancel(self.durationTimeout); - self.isShown = false; - var loading = self.element.children(); - loading.html(""); - }; - - return self; - }); - } - return loaderInstance; - } - - function showLoader(options) { - options = extend({}, $ionicLoadingConfig || {}, options || {}); - // use a default delay of 100 to avoid some issues reported on github - // https://github.com/driftyco/ionic/issues/3717 - var delay = options.delay || options.showDelay || 0; - - deregisterStateListener1(); - deregisterStateListener2(); - if (options.hideOnStateChange) { - deregisterStateListener1 = $rootScope.$on('$stateChangeSuccess', hideLoader); - deregisterStateListener2 = $rootScope.$on('$stateChangeError', hideLoader); - } - - //If loading.show() was called previously, cancel it and show with our new options - $timeout.cancel(loadingShowDelay); - loadingShowDelay = $timeout(noop, delay); - return loadingShowDelay.then(getLoader).then(function(loader) { - return loader.show(options); - }); - } - - function hideLoader() { - deregisterStateListener1(); - deregisterStateListener2(); - $timeout.cancel(loadingShowDelay); - return getLoader().then(function(loader) { - return loader.hide(); - }); - } -}]); - -/** - * @ngdoc service - * @name $ionicModal - * @module ionic - * @codepen gblny - * @description - * - * Related: {@link ionic.controller:ionicModal ionicModal controller}. - * - * The Modal is a content pane that can go over the user's main view - * temporarily. Usually used for making a choice or editing an item. - * - * Put the content of the modal inside of an `` element. - * - * **Notes:** - * - A modal will broadcast 'modal.shown', 'modal.hidden', and 'modal.removed' events from its originating - * scope, passing in itself as an event argument. Both the modal.removed and modal.hidden events are - * called when the modal is removed. - * - * - This example assumes your modal is in your main index file or another template file. If it is in its own - * template file, remove the script tags and call it by file name. - * - * @usage - * ```html - * - * ``` - * ```js - * angular.module('testApp', ['ionic']) - * .controller('MyController', function($scope, $ionicModal) { - * $ionicModal.fromTemplateUrl('my-modal.html', { - * scope: $scope, - * animation: 'slide-in-up' - * }).then(function(modal) { - * $scope.modal = modal; - * }); - * $scope.openModal = function() { - * $scope.modal.show(); - * }; - * $scope.closeModal = function() { - * $scope.modal.hide(); - * }; - * // Cleanup the modal when we're done with it! - * $scope.$on('$destroy', function() { - * $scope.modal.remove(); - * }); - * // Execute action on hide modal - * $scope.$on('modal.hidden', function() { - * // Execute action - * }); - * // Execute action on remove modal - * $scope.$on('modal.removed', function() { - * // Execute action - * }); - * }); - * ``` - */ -IonicModule -.factory('$ionicModal', [ - '$rootScope', - '$ionicBody', - '$compile', - '$timeout', - '$ionicPlatform', - '$ionicTemplateLoader', - '$$q', - '$log', - '$ionicClickBlock', - '$window', - 'IONIC_BACK_PRIORITY', -function($rootScope, $ionicBody, $compile, $timeout, $ionicPlatform, $ionicTemplateLoader, $$q, $log, $ionicClickBlock, $window, IONIC_BACK_PRIORITY) { - - /** - * @ngdoc controller - * @name ionicModal - * @module ionic - * @description - * Instantiated by the {@link ionic.service:$ionicModal} service. - * - * Be sure to call [remove()](#remove) when you are done with each modal - * to clean it up and avoid memory leaks. - * - * Note: a modal will broadcast 'modal.shown', 'modal.hidden', and 'modal.removed' events from its originating - * scope, passing in itself as an event argument. Note: both modal.removed and modal.hidden are - * called when the modal is removed. - */ - var ModalView = ionic.views.Modal.inherit({ - /** - * @ngdoc method - * @name ionicModal#initialize - * @description Creates a new modal controller instance. - * @param {object} options An options object with the following properties: - * - `{object=}` `scope` The scope to be a child of. - * Default: creates a child of $rootScope. - * - `{string=}` `animation` The animation to show & hide with. - * Default: 'slide-in-up' - * - `{boolean=}` `focusFirstInput` Whether to autofocus the first input of - * the modal when shown. Will only show the keyboard on iOS, to force the keyboard to show - * on Android, please use the [Ionic keyboard plugin](https://github.com/driftyco/ionic-plugin-keyboard#keyboardshow). - * Default: false. - * - `{boolean=}` `backdropClickToClose` Whether to close the modal on clicking the backdrop. - * Default: true. - * - `{boolean=}` `hardwareBackButtonClose` Whether the modal can be closed using the hardware - * back button on Android and similar devices. Default: true. - */ - initialize: function(opts) { - ionic.views.Modal.prototype.initialize.call(this, opts); - this.animation = opts.animation || 'slide-in-up'; - }, - - /** - * @ngdoc method - * @name ionicModal#show - * @description Show this modal instance. - * @returns {promise} A promise which is resolved when the modal is finished animating in. - */ - show: function(target) { - var self = this; - - if (self.scope.$$destroyed) { - $log.error('Cannot call ' + self.viewType + '.show() after remove(). Please create a new ' + self.viewType + ' instance.'); - return $$q.when(); - } - - // on iOS, clicks will sometimes bleed through/ghost click on underlying - // elements - $ionicClickBlock.show(600); - stack.add(self); - - var modalEl = jqLite(self.modalEl); - - self.el.classList.remove('hide'); - $timeout(function() { - if (!self._isShown) return; - $ionicBody.addClass(self.viewType + '-open'); - }, 400, false); - - if (!self.el.parentElement) { - modalEl.addClass(self.animation); - $ionicBody.append(self.el); - } - - // if modal was closed while the keyboard was up, reset scroll view on - // next show since we can only resize it once it's visible - var scrollCtrl = modalEl.data('$$ionicScrollController'); - scrollCtrl && scrollCtrl.resize(); - - if (target && self.positionView) { - self.positionView(target, modalEl); - // set up a listener for in case the window size changes - - self._onWindowResize = function() { - if (self._isShown) self.positionView(target, modalEl); - }; - ionic.on('resize', self._onWindowResize, window); - } - - modalEl.addClass('ng-enter active') - .removeClass('ng-leave ng-leave-active'); - - self._isShown = true; - self._deregisterBackButton = $ionicPlatform.registerBackButtonAction( - self.hardwareBackButtonClose ? angular.bind(self, self.hide) : noop, - IONIC_BACK_PRIORITY.modal - ); - - ionic.views.Modal.prototype.show.call(self); - - $timeout(function() { - if (!self._isShown) return; - modalEl.addClass('ng-enter-active'); - ionic.trigger('resize'); - self.scope.$parent && self.scope.$parent.$broadcast(self.viewType + '.shown', self); - self.el.classList.add('active'); - self.scope.$broadcast('$ionicHeader.align'); - self.scope.$broadcast('$ionicFooter.align'); - self.scope.$broadcast('$ionic.modalPresented'); - }, 20); - - return $timeout(function() { - if (!self._isShown) return; - self.$el.on('touchmove', function(e) { - //Don't allow scrolling while open by dragging on backdrop - var isInScroll = ionic.DomUtil.getParentOrSelfWithClass(e.target, 'scroll'); - if (!isInScroll) { - e.preventDefault(); - } - }); - //After animating in, allow hide on backdrop click - self.$el.on('click', function(e) { - if (self.backdropClickToClose && e.target === self.el && stack.isHighest(self)) { - self.hide(); - } - }); - }, 400); - }, - - /** - * @ngdoc method - * @name ionicModal#hide - * @description Hide this modal instance. - * @returns {promise} A promise which is resolved when the modal is finished animating out. - */ - hide: function() { - var self = this; - var modalEl = jqLite(self.modalEl); - - // on iOS, clicks will sometimes bleed through/ghost click on underlying - // elements - $ionicClickBlock.show(600); - stack.remove(self); - - self.el.classList.remove('active'); - modalEl.addClass('ng-leave'); - - $timeout(function() { - if (self._isShown) return; - modalEl.addClass('ng-leave-active') - .removeClass('ng-enter ng-enter-active active'); - - self.scope.$broadcast('$ionic.modalRemoved'); - }, 20, false); - - self.$el.off('click'); - self._isShown = false; - self.scope.$parent && self.scope.$parent.$broadcast(self.viewType + '.hidden', self); - self._deregisterBackButton && self._deregisterBackButton(); - - ionic.views.Modal.prototype.hide.call(self); - - // clean up event listeners - if (self.positionView) { - ionic.off('resize', self._onWindowResize, window); - } - - return $timeout(function() { - if (!modalStack.length) { - $ionicBody.removeClass(self.viewType + '-open'); - } - self.el.classList.add('hide'); - }, self.hideDelay || 320); - }, - - /** - * @ngdoc method - * @name ionicModal#remove - * @description Remove this modal instance from the DOM and clean up. - * @returns {promise} A promise which is resolved when the modal is finished animating out. - */ - remove: function() { - var self = this, - deferred, promise; - self.scope.$parent && self.scope.$parent.$broadcast(self.viewType + '.removed', self); - - // Only hide modal, when it is actually shown! - // The hide function shows a click-block-div for a split second, because on iOS, - // clicks will sometimes bleed through/ghost click on underlying elements. - // However, this will make the app unresponsive for short amount of time. - // We don't want that, if the modal window is already hidden. - if (self._isShown) { - promise = self.hide(); - } else { - deferred = $$q.defer(); - deferred.resolve(); - promise = deferred.promise; - } - - return promise.then(function() { - self.scope.$destroy(); - self.$el.remove(); - }); - }, - - /** - * @ngdoc method - * @name ionicModal#isShown - * @returns boolean Whether this modal is currently shown. - */ - isShown: function() { - return !!this._isShown; - } - }); - - var createModal = function(templateString, options) { - // Create a new scope for the modal - var scope = options.scope && options.scope.$new() || $rootScope.$new(true); - - options.viewType = options.viewType || 'modal'; - - extend(scope, { - $hasHeader: false, - $hasSubheader: false, - $hasFooter: false, - $hasSubfooter: false, - $hasTabs: false, - $hasTabsTop: false - }); - - // Compile the template - var element = $compile('' + templateString + '')(scope); - - options.$el = element; - options.el = element[0]; - options.modalEl = options.el.querySelector('.' + options.viewType); - var modal = new ModalView(options); - - modal.scope = scope; - - // If this wasn't a defined scope, we can assign the viewType to the isolated scope - // we created - if (!options.scope) { - scope[ options.viewType ] = modal; - } - - return modal; - }; - - var modalStack = []; - var stack = { - add: function(modal) { - modalStack.push(modal); - }, - remove: function(modal) { - var index = modalStack.indexOf(modal); - if (index > -1 && index < modalStack.length) { - modalStack.splice(index, 1); - } - }, - isHighest: function(modal) { - var index = modalStack.indexOf(modal); - return (index > -1 && index === modalStack.length - 1); - } - }; - - return { - /** - * @ngdoc method - * @name $ionicModal#fromTemplate - * @param {string} templateString The template string to use as the modal's - * content. - * @param {object} options Options to be passed {@link ionic.controller:ionicModal#initialize ionicModal#initialize} method. - * @returns {object} An instance of an {@link ionic.controller:ionicModal} - * controller. - */ - fromTemplate: function(templateString, options) { - var modal = createModal(templateString, options || {}); - return modal; - }, - /** - * @ngdoc method - * @name $ionicModal#fromTemplateUrl - * @param {string} templateUrl The url to load the template from. - * @param {object} options Options to be passed {@link ionic.controller:ionicModal#initialize ionicModal#initialize} method. - * options object. - * @returns {promise} A promise that will be resolved with an instance of - * an {@link ionic.controller:ionicModal} controller. - */ - fromTemplateUrl: function(url, options, _) { - var cb; - //Deprecated: allow a callback as second parameter. Now we return a promise. - if (angular.isFunction(options)) { - cb = options; - options = _; - } - return $ionicTemplateLoader.load(url).then(function(templateString) { - var modal = createModal(templateString, options || {}); - cb && cb(modal); - return modal; - }); - }, - - stack: stack - }; -}]); - - -/** - * @ngdoc service - * @name $ionicNavBarDelegate - * @module ionic - * @description - * Delegate for controlling the {@link ionic.directive:ionNavBar} directive. - * - * @usage - * - * ```html - * - * - * - * - * - * ``` - * ```js - * function MyCtrl($scope, $ionicNavBarDelegate) { - * $scope.setNavTitle = function(title) { - * $ionicNavBarDelegate.title(title); - * } - * } - * ``` - */ -IonicModule -.service('$ionicNavBarDelegate', ionic.DelegateService([ - /** - * @ngdoc method - * @name $ionicNavBarDelegate#align - * @description Aligns the title with the buttons in a given direction. - * @param {string=} direction The direction to the align the title text towards. - * Available: 'left', 'right', 'center'. Default: 'center'. - */ - 'align', - /** - * @ngdoc method - * @name $ionicNavBarDelegate#showBackButton - * @description - * Set/get whether the {@link ionic.directive:ionNavBackButton} is shown - * (if it exists and there is a previous view that can be navigated to). - * @param {boolean=} show Whether to show the back button. - * @returns {boolean} Whether the back button is shown. - */ - 'showBackButton', - /** - * @ngdoc method - * @name $ionicNavBarDelegate#showBar - * @description - * Set/get whether the {@link ionic.directive:ionNavBar} is shown. - * @param {boolean} show Whether to show the bar. - * @returns {boolean} Whether the bar is shown. - */ - 'showBar', - /** - * @ngdoc method - * @name $ionicNavBarDelegate#title - * @description - * Set the title for the {@link ionic.directive:ionNavBar}. - * @param {string} title The new title to show. - */ - 'title', - - // DEPRECATED, as of v1.0.0-beta14 ------- - 'changeTitle', - 'setTitle', - 'getTitle', - 'back', - 'getPreviousTitle' - // END DEPRECATED ------- -])); - - -IonicModule -.service('$ionicNavViewDelegate', ionic.DelegateService([ - 'clearCache' -])); - - - -/** - * @ngdoc service - * @name $ionicPlatform - * @module ionic - * @description - * An angular abstraction of {@link ionic.utility:ionic.Platform}. - * - * Used to detect the current platform, as well as do things like override the - * Android back button in PhoneGap/Cordova. - */ -IonicModule -.constant('IONIC_BACK_PRIORITY', { - view: 100, - sideMenu: 150, - modal: 200, - actionSheet: 300, - popup: 400, - loading: 500 -}) -.provider('$ionicPlatform', function() { - return { - $get: ['$q', '$ionicScrollDelegate', function($q, $ionicScrollDelegate) { - var self = { - - /** - * @ngdoc method - * @name $ionicPlatform#onHardwareBackButton - * @description - * Some platforms have a hardware back button, so this is one way to - * bind to it. - * @param {function} callback the callback to trigger when this event occurs - */ - onHardwareBackButton: function(cb) { - ionic.Platform.ready(function() { - document.addEventListener('backbutton', cb, false); - }); - }, - - /** - * @ngdoc method - * @name $ionicPlatform#offHardwareBackButton - * @description - * Remove an event listener for the backbutton. - * @param {function} callback The listener function that was - * originally bound. - */ - offHardwareBackButton: function(fn) { - ionic.Platform.ready(function() { - document.removeEventListener('backbutton', fn); - }); - }, - - /** - * @ngdoc method - * @name $ionicPlatform#registerBackButtonAction - * @description - * Register a hardware back button action. Only one action will execute - * when the back button is clicked, so this method decides which of - * the registered back button actions has the highest priority. - * - * For example, if an actionsheet is showing, the back button should - * close the actionsheet, but it should not also go back a page view - * or close a modal which may be open. - * - * The priorities for the existing back button hooks are as follows: - * Return to previous view = 100 - * Close side menu = 150 - * Dismiss modal = 200 - * Close action sheet = 300 - * Dismiss popup = 400 - * Dismiss loading overlay = 500 - * - * Your back button action will override each of the above actions - * whose priority is less than the priority you provide. For example, - * an action assigned a priority of 101 will override the 'return to - * previous view' action, but not any of the other actions. - * - * @param {function} callback Called when the back button is pressed, - * if this listener is the highest priority. - * @param {number} priority Only the highest priority will execute. - * @param {*=} actionId The id to assign this action. Default: a - * random unique id. - * @returns {function} A function that, when called, will deregister - * this backButtonAction. - */ - $backButtonActions: {}, - registerBackButtonAction: function(fn, priority, actionId) { - - if (!self._hasBackButtonHandler) { - // add a back button listener if one hasn't been setup yet - self.$backButtonActions = {}; - self.onHardwareBackButton(self.hardwareBackButtonClick); - self._hasBackButtonHandler = true; - } - - var action = { - id: (actionId ? actionId : ionic.Utils.nextUid()), - priority: (priority ? priority : 0), - fn: fn - }; - self.$backButtonActions[action.id] = action; - - // return a function to de-register this back button action - return function() { - delete self.$backButtonActions[action.id]; - }; - }, - - /** - * @private - */ - hardwareBackButtonClick: function(e) { - // loop through all the registered back button actions - // and only run the last one of the highest priority - var priorityAction, actionId; - for (actionId in self.$backButtonActions) { - if (!priorityAction || self.$backButtonActions[actionId].priority >= priorityAction.priority) { - priorityAction = self.$backButtonActions[actionId]; - } - } - if (priorityAction) { - priorityAction.fn(e); - return priorityAction; - } - }, - - is: function(type) { - return ionic.Platform.is(type); - }, - - /** - * @ngdoc method - * @name $ionicPlatform#on - * @description - * Add Cordova event listeners, such as `pause`, `resume`, `volumedownbutton`, `batterylow`, - * `offline`, etc. More information about available event types can be found in - * [Cordova's event documentation](https://cordova.apache.org/docs/en/latest/cordova/events/events.html). - * @param {string} type Cordova [event type](https://cordova.apache.org/docs/en/latest/cordova/events/events.html). - * @param {function} callback Called when the Cordova event is fired. - * @returns {function} Returns a deregistration function to remove the event listener. - */ - on: function(type, cb) { - ionic.Platform.ready(function() { - document.addEventListener(type, cb, false); - }); - return function() { - ionic.Platform.ready(function() { - document.removeEventListener(type, cb); - }); - }; - }, - - /** - * @ngdoc method - * @name $ionicPlatform#ready - * @description - * Trigger a callback once the device is ready, - * or immediately if the device is already ready. - * @param {function=} callback The function to call. - * @returns {promise} A promise which is resolved when the device is ready. - */ - ready: function(cb) { - var q = $q.defer(); - - ionic.Platform.ready(function() { - - window.addEventListener('statusTap', function() { - $ionicScrollDelegate.scrollTop(true); - }); - - q.resolve(); - cb && cb(); - }); - - return q.promise; - } - }; - - return self; - }] - }; - -}); - -/** - * @ngdoc service - * @name $ionicPopover - * @module ionic - * @description - * - * Related: {@link ionic.controller:ionicPopover ionicPopover controller}. - * - * The Popover is a view that floats above an appā€™s content. Popovers provide an - * easy way to present or gather information from the user and are - * commonly used in the following situations: - * - * - Show more info about the current view - * - Select a commonly used tool or configuration - * - Present a list of actions to perform inside one of your views - * - * Put the content of the popover inside of an `` element. - * - * @usage - * ```html - *

    - * - *

    - * - * - * ``` - * ```js - * angular.module('testApp', ['ionic']) - * .controller('MyController', function($scope, $ionicPopover) { - * - * // .fromTemplate() method - * var template = '

    My Popover Title

    Hello!
    '; - * - * $scope.popover = $ionicPopover.fromTemplate(template, { - * scope: $scope - * }); - * - * // .fromTemplateUrl() method - * $ionicPopover.fromTemplateUrl('my-popover.html', { - * scope: $scope - * }).then(function(popover) { - * $scope.popover = popover; - * }); - * - * - * $scope.openPopover = function($event) { - * $scope.popover.show($event); - * }; - * $scope.closePopover = function() { - * $scope.popover.hide(); - * }; - * //Cleanup the popover when we're done with it! - * $scope.$on('$destroy', function() { - * $scope.popover.remove(); - * }); - * // Execute action on hidden popover - * $scope.$on('popover.hidden', function() { - * // Execute action - * }); - * // Execute action on remove popover - * $scope.$on('popover.removed', function() { - * // Execute action - * }); - * }); - * ``` - */ - - -IonicModule -.factory('$ionicPopover', ['$ionicModal', '$ionicPosition', '$document', '$window', -function($ionicModal, $ionicPosition, $document, $window) { - - var POPOVER_BODY_PADDING = 6; - - var POPOVER_OPTIONS = { - viewType: 'popover', - hideDelay: 1, - animation: 'none', - positionView: positionView - }; - - function positionView(target, popoverEle) { - var targetEle = jqLite(target.target || target); - var buttonOffset = $ionicPosition.offset(targetEle); - var popoverWidth = popoverEle.prop('offsetWidth'); - var popoverHeight = popoverEle.prop('offsetHeight'); - // Use innerWidth and innerHeight, because clientWidth and clientHeight - // doesn't work consistently for body on all platforms - var bodyWidth = $window.innerWidth; - var bodyHeight = $window.innerHeight; - - var popoverCSS = { - left: buttonOffset.left + buttonOffset.width / 2 - popoverWidth / 2 - }; - var arrowEle = jqLite(popoverEle[0].querySelector('.popover-arrow')); - - if (popoverCSS.left < POPOVER_BODY_PADDING) { - popoverCSS.left = POPOVER_BODY_PADDING; - } else if (popoverCSS.left + popoverWidth + POPOVER_BODY_PADDING > bodyWidth) { - popoverCSS.left = bodyWidth - popoverWidth - POPOVER_BODY_PADDING; - } - - // If the popover when popped down stretches past bottom of screen, - // make it pop up if there's room above - if (buttonOffset.top + buttonOffset.height + popoverHeight > bodyHeight && - buttonOffset.top - popoverHeight > 0) { - popoverCSS.top = buttonOffset.top - popoverHeight; - popoverEle.addClass('popover-bottom'); - } else { - popoverCSS.top = buttonOffset.top + buttonOffset.height; - popoverEle.removeClass('popover-bottom'); - } - - arrowEle.css({ - left: buttonOffset.left + buttonOffset.width / 2 - - arrowEle.prop('offsetWidth') / 2 - popoverCSS.left + 'px' - }); - - popoverEle.css({ - top: popoverCSS.top + 'px', - left: popoverCSS.left + 'px', - marginLeft: '0', - opacity: '1' - }); - - } - - /** - * @ngdoc controller - * @name ionicPopover - * @module ionic - * @description - * Instantiated by the {@link ionic.service:$ionicPopover} service. - * - * Be sure to call [remove()](#remove) when you are done with each popover - * to clean it up and avoid memory leaks. - * - * Note: a popover will broadcast 'popover.shown', 'popover.hidden', and 'popover.removed' events from its originating - * scope, passing in itself as an event argument. Both the popover.removed and popover.hidden events are - * called when the popover is removed. - */ - - /** - * @ngdoc method - * @name ionicPopover#initialize - * @description Creates a new popover controller instance. - * @param {object} options An options object with the following properties: - * - `{object=}` `scope` The scope to be a child of. - * Default: creates a child of $rootScope. - * - `{boolean=}` `focusFirstInput` Whether to autofocus the first input of - * the popover when shown. Default: false. - * - `{boolean=}` `backdropClickToClose` Whether to close the popover on clicking the backdrop. - * Default: true. - * - `{boolean=}` `hardwareBackButtonClose` Whether the popover can be closed using the hardware - * back button on Android and similar devices. Default: true. - */ - - /** - * @ngdoc method - * @name ionicPopover#show - * @description Show this popover instance. - * @param {$event} $event The $event or target element which the popover should align - * itself next to. - * @returns {promise} A promise which is resolved when the popover is finished animating in. - */ - - /** - * @ngdoc method - * @name ionicPopover#hide - * @description Hide this popover instance. - * @returns {promise} A promise which is resolved when the popover is finished animating out. - */ - - /** - * @ngdoc method - * @name ionicPopover#remove - * @description Remove this popover instance from the DOM and clean up. - * @returns {promise} A promise which is resolved when the popover is finished animating out. - */ - - /** - * @ngdoc method - * @name ionicPopover#isShown - * @returns boolean Whether this popover is currently shown. - */ - - return { - /** - * @ngdoc method - * @name $ionicPopover#fromTemplate - * @param {string} templateString The template string to use as the popovers's - * content. - * @param {object} options Options to be passed to the initialize method. - * @returns {object} An instance of an {@link ionic.controller:ionicPopover} - * controller (ionicPopover is built on top of $ionicPopover). - */ - fromTemplate: function(templateString, options) { - return $ionicModal.fromTemplate(templateString, ionic.Utils.extend({}, POPOVER_OPTIONS, options)); - }, - /** - * @ngdoc method - * @name $ionicPopover#fromTemplateUrl - * @param {string} templateUrl The url to load the template from. - * @param {object} options Options to be passed to the initialize method. - * @returns {promise} A promise that will be resolved with an instance of - * an {@link ionic.controller:ionicPopover} controller (ionicPopover is built on top of $ionicPopover). - */ - fromTemplateUrl: function(url, options) { - return $ionicModal.fromTemplateUrl(url, ionic.Utils.extend({}, POPOVER_OPTIONS, options)); - } - }; - -}]); - - -var POPUP_TPL = - ''; - -/** - * @ngdoc service - * @name $ionicPopup - * @module ionic - * @restrict E - * @codepen zkmhJ - * @description - * - * The Ionic Popup service allows programmatically creating and showing popup - * windows that require the user to respond in order to continue. - * - * The popup system has support for more flexible versions of the built in `alert()`, `prompt()`, - * and `confirm()` functions that users are used to, in addition to allowing popups with completely - * custom content and look. - * - * An input can be given an `autofocus` attribute so it automatically receives focus when - * the popup first shows. However, depending on certain use-cases this can cause issues with - * the tap/click system, which is why Ionic prefers using the `autofocus` attribute as - * an opt-in feature and not the default. - * - * @usage - * A few basic examples, see below for details about all of the options available. - * - * ```js - *angular.module('mySuperApp', ['ionic']) - *.controller('PopupCtrl',function($scope, $ionicPopup, $timeout) { - * - * // Triggered on a button click, or some other target - * $scope.showPopup = function() { - * $scope.data = {}; - * - * // An elaborate, custom popup - * var myPopup = $ionicPopup.show({ - * template: '', - * title: 'Enter Wi-Fi Password', - * subTitle: 'Please use normal things', - * scope: $scope, - * buttons: [ - * { text: 'Cancel' }, - * { - * text: 'Save', - * type: 'button-positive', - * onTap: function(e) { - * if (!$scope.data.wifi) { - * //don't allow the user to close unless he enters wifi password - * e.preventDefault(); - * } else { - * return $scope.data.wifi; - * } - * } - * } - * ] - * }); - * - * myPopup.then(function(res) { - * console.log('Tapped!', res); - * }); - * - * $timeout(function() { - * myPopup.close(); //close the popup after 3 seconds for some reason - * }, 3000); - * }; - * - * // A confirm dialog - * $scope.showConfirm = function() { - * var confirmPopup = $ionicPopup.confirm({ - * title: 'Consume Ice Cream', - * template: 'Are you sure you want to eat this ice cream?' - * }); - * - * confirmPopup.then(function(res) { - * if(res) { - * console.log('You are sure'); - * } else { - * console.log('You are not sure'); - * } - * }); - * }; - * - * // An alert dialog - * $scope.showAlert = function() { - * var alertPopup = $ionicPopup.alert({ - * title: 'Don\'t eat that!', - * template: 'It might taste good' - * }); - * - * alertPopup.then(function(res) { - * console.log('Thank you for not eating my delicious ice cream cone'); - * }); - * }; - *}); - *``` - */ - -IonicModule -.factory('$ionicPopup', [ - '$ionicTemplateLoader', - '$ionicBackdrop', - '$q', - '$timeout', - '$rootScope', - '$ionicBody', - '$compile', - '$ionicPlatform', - '$ionicModal', - 'IONIC_BACK_PRIORITY', -function($ionicTemplateLoader, $ionicBackdrop, $q, $timeout, $rootScope, $ionicBody, $compile, $ionicPlatform, $ionicModal, IONIC_BACK_PRIORITY) { - //TODO allow this to be configured - var config = { - stackPushDelay: 75 - }; - var popupStack = []; - - var $ionicPopup = { - /** - * @ngdoc method - * @description - * Show a complex popup. This is the master show function for all popups. - * - * A complex popup has a `buttons` array, with each button having a `text` and `type` - * field, in addition to an `onTap` function. The `onTap` function, called when - * the corresponding button on the popup is tapped, will by default close the popup - * and resolve the popup promise with its return value. If you wish to prevent the - * default and keep the popup open on button tap, call `event.preventDefault()` on the - * passed in tap event. Details below. - * - * @name $ionicPopup#show - * @param {object} options The options for the new popup, of the form: - * - * ``` - * { - * title: '', // String. The title of the popup. - * cssClass: '', // String, The custom CSS class name - * subTitle: '', // String (optional). The sub-title of the popup. - * template: '', // String (optional). The html template to place in the popup body. - * templateUrl: '', // String (optional). The URL of an html template to place in the popup body. - * scope: null, // Scope (optional). A scope to link to the popup content. - * buttons: [{ // Array[Object] (optional). Buttons to place in the popup footer. - * text: 'Cancel', - * type: 'button-default', - * onTap: function(e) { - * // e.preventDefault() will stop the popup from closing when tapped. - * e.preventDefault(); - * } - * }, { - * text: 'OK', - * type: 'button-positive', - * onTap: function(e) { - * // Returning a value will cause the promise to resolve with the given value. - * return scope.data.response; - * } - * }] - * } - * ``` - * - * @returns {object} A promise which is resolved when the popup is closed. Has an additional - * `close` function, which can be used to programmatically close the popup. - */ - show: showPopup, - - /** - * @ngdoc method - * @name $ionicPopup#alert - * @description Show a simple alert popup with a message and one button that the user can - * tap to close the popup. - * - * @param {object} options The options for showing the alert, of the form: - * - * ``` - * { - * title: '', // String. The title of the popup. - * cssClass: '', // String, The custom CSS class name - * subTitle: '', // String (optional). The sub-title of the popup. - * template: '', // String (optional). The html template to place in the popup body. - * templateUrl: '', // String (optional). The URL of an html template to place in the popup body. - * okText: '', // String (default: 'OK'). The text of the OK button. - * okType: '', // String (default: 'button-positive'). The type of the OK button. - * } - * ``` - * - * @returns {object} A promise which is resolved when the popup is closed. Has one additional - * function `close`, which can be called with any value to programmatically close the popup - * with the given value. - */ - alert: showAlert, - - /** - * @ngdoc method - * @name $ionicPopup#confirm - * @description - * Show a simple confirm popup with a Cancel and OK button. - * - * Resolves the promise with true if the user presses the OK button, and false if the - * user presses the Cancel button. - * - * @param {object} options The options for showing the confirm popup, of the form: - * - * ``` - * { - * title: '', // String. The title of the popup. - * cssClass: '', // String, The custom CSS class name - * subTitle: '', // String (optional). The sub-title of the popup. - * template: '', // String (optional). The html template to place in the popup body. - * templateUrl: '', // String (optional). The URL of an html template to place in the popup body. - * cancelText: '', // String (default: 'Cancel'). The text of the Cancel button. - * cancelType: '', // String (default: 'button-default'). The type of the Cancel button. - * okText: '', // String (default: 'OK'). The text of the OK button. - * okType: '', // String (default: 'button-positive'). The type of the OK button. - * } - * ``` - * - * @returns {object} A promise which is resolved when the popup is closed. Has one additional - * function `close`, which can be called with any value to programmatically close the popup - * with the given value. - */ - confirm: showConfirm, - - /** - * @ngdoc method - * @name $ionicPopup#prompt - * @description Show a simple prompt popup, which has an input, OK button, and Cancel button. - * Resolves the promise with the value of the input if the user presses OK, and with undefined - * if the user presses Cancel. - * - * ```javascript - * $ionicPopup.prompt({ - * title: 'Password Check', - * template: 'Enter your secret password', - * inputType: 'password', - * inputPlaceholder: 'Your password' - * }).then(function(res) { - * console.log('Your password is', res); - * }); - * ``` - * @param {object} options The options for showing the prompt popup, of the form: - * - * ``` - * { - * title: '', // String. The title of the popup. - * cssClass: '', // String, The custom CSS class name - * subTitle: '', // String (optional). The sub-title of the popup. - * template: '', // String (optional). The html template to place in the popup body. - * templateUrl: '', // String (optional). The URL of an html template to place in the popup body. - * inputType: // String (default: 'text'). The type of input to use - * defaultText: // String (default: ''). The initial value placed into the input. - * maxLength: // Integer (default: null). Specify a maxlength attribute for the input. - * inputPlaceholder: // String (default: ''). A placeholder to use for the input. - * cancelText: // String (default: 'Cancel'. The text of the Cancel button. - * cancelType: // String (default: 'button-default'). The type of the Cancel button. - * okText: // String (default: 'OK'). The text of the OK button. - * okType: // String (default: 'button-positive'). The type of the OK button. - * } - * ``` - * - * @returns {object} A promise which is resolved when the popup is closed. Has one additional - * function `close`, which can be called with any value to programmatically close the popup - * with the given value. - */ - prompt: showPrompt, - /** - * @private for testing - */ - _createPopup: createPopup, - _popupStack: popupStack - }; - - return $ionicPopup; - - function createPopup(options) { - options = extend({ - scope: null, - title: '', - buttons: [] - }, options || {}); - - var self = {}; - self.scope = (options.scope || $rootScope).$new(); - self.element = jqLite(POPUP_TPL); - self.responseDeferred = $q.defer(); - - $ionicBody.get().appendChild(self.element[0]); - $compile(self.element)(self.scope); - - extend(self.scope, { - title: options.title, - buttons: options.buttons, - subTitle: options.subTitle, - cssClass: options.cssClass, - $buttonTapped: function(button, event) { - var result = (button.onTap || noop).apply(self, [event]); - event = event.originalEvent || event; //jquery events - - if (!event.defaultPrevented) { - self.responseDeferred.resolve(result); - } - } - }); - - $q.when( - options.templateUrl ? - $ionicTemplateLoader.load(options.templateUrl) : - (options.template || options.content || '') - ).then(function(template) { - var popupBody = jqLite(self.element[0].querySelector('.popup-body')); - if (template) { - popupBody.html(template); - $compile(popupBody.contents())(self.scope); - } else { - popupBody.remove(); - } - }); - - self.show = function() { - if (self.isShown || self.removed) return; - - $ionicModal.stack.add(self); - self.isShown = true; - ionic.requestAnimationFrame(function() { - //if hidden while waiting for raf, don't show - if (!self.isShown) return; - - self.element.removeClass('popup-hidden'); - self.element.addClass('popup-showing active'); - focusInput(self.element); - }); - }; - - self.hide = function(callback) { - callback = callback || noop; - if (!self.isShown) return callback(); - - $ionicModal.stack.remove(self); - self.isShown = false; - self.element.removeClass('active'); - self.element.addClass('popup-hidden'); - $timeout(callback, 250, false); - }; - - self.remove = function() { - if (self.removed) return; - - self.hide(function() { - self.element.remove(); - self.scope.$destroy(); - }); - - self.removed = true; - }; - - return self; - } - - function onHardwareBackButton() { - var last = popupStack[popupStack.length - 1]; - last && last.responseDeferred.resolve(); - } - - function showPopup(options) { - var popup = $ionicPopup._createPopup(options); - var showDelay = 0; - - if (popupStack.length > 0) { - showDelay = config.stackPushDelay; - $timeout(popupStack[popupStack.length - 1].hide, showDelay, false); - } else { - //Add popup-open & backdrop if this is first popup - $ionicBody.addClass('popup-open'); - $ionicBackdrop.retain(); - //only show the backdrop on the first popup - $ionicPopup._backButtonActionDone = $ionicPlatform.registerBackButtonAction( - onHardwareBackButton, - IONIC_BACK_PRIORITY.popup - ); - } - - // Expose a 'close' method on the returned promise - popup.responseDeferred.promise.close = function popupClose(result) { - if (!popup.removed) popup.responseDeferred.resolve(result); - }; - //DEPRECATED: notify the promise with an object with a close method - popup.responseDeferred.notify({ close: popup.responseDeferred.close }); - - doShow(); - - return popup.responseDeferred.promise; - - function doShow() { - popupStack.push(popup); - $timeout(popup.show, showDelay, false); - - popup.responseDeferred.promise.then(function(result) { - var index = popupStack.indexOf(popup); - if (index !== -1) { - popupStack.splice(index, 1); - } - - popup.remove(); - - if (popupStack.length > 0) { - popupStack[popupStack.length - 1].show(); - } else { - $ionicBackdrop.release(); - //Remove popup-open & backdrop if this is last popup - $timeout(function() { - // wait to remove this due to a 300ms delay native - // click which would trigging whatever was underneath this - if (!popupStack.length) { - $ionicBody.removeClass('popup-open'); - } - }, 400, false); - ($ionicPopup._backButtonActionDone || noop)(); - } - - - return result; - }); - - } - - } - - function focusInput(element) { - var focusOn = element[0].querySelector('[autofocus]'); - if (focusOn) { - focusOn.focus(); - } - } - - function showAlert(opts) { - return showPopup(extend({ - buttons: [{ - text: opts.okText || 'OK', - type: opts.okType || 'button-positive', - onTap: function() { - return true; - } - }] - }, opts || {})); - } - - function showConfirm(opts) { - return showPopup(extend({ - buttons: [{ - text: opts.cancelText || 'Cancel', - type: opts.cancelType || 'button-default', - onTap: function() { return false; } - }, { - text: opts.okText || 'OK', - type: opts.okType || 'button-positive', - onTap: function() { return true; } - }] - }, opts || {})); - } - - function showPrompt(opts) { - var scope = $rootScope.$new(true); - scope.data = {}; - scope.data.fieldtype = opts.inputType ? opts.inputType : 'text'; - scope.data.response = opts.defaultText ? opts.defaultText : ''; - scope.data.placeholder = opts.inputPlaceholder ? opts.inputPlaceholder : ''; - scope.data.maxlength = opts.maxLength ? parseInt(opts.maxLength) : ''; - var text = ''; - if (opts.template && /<[a-z][\s\S]*>/i.test(opts.template) === false) { - text = '' + opts.template + ''; - delete opts.template; - } - return showPopup(extend({ - template: text + '', - scope: scope, - buttons: [{ - text: opts.cancelText || 'Cancel', - type: opts.cancelType || 'button-default', - onTap: function() {} - }, { - text: opts.okText || 'OK', - type: opts.okType || 'button-positive', - onTap: function() { - return scope.data.response || ''; - } - }] - }, opts || {})); - } -}]); - -/** - * @ngdoc service - * @name $ionicPosition - * @module ionic - * @description - * A set of utility methods that can be use to retrieve position of DOM elements. - * It is meant to be used where we need to absolute-position DOM elements in - * relation to other, existing elements (this is the case for tooltips, popovers, etc.). - * - * Adapted from [AngularUI Bootstrap](https://github.com/angular-ui/bootstrap/blob/master/src/position/position.js), - * ([license](https://github.com/angular-ui/bootstrap/blob/master/LICENSE)) - */ -IonicModule -.factory('$ionicPosition', ['$document', '$window', function($document, $window) { - - function getStyle(el, cssprop) { - if (el.currentStyle) { //IE - return el.currentStyle[cssprop]; - } else if ($window.getComputedStyle) { - return $window.getComputedStyle(el)[cssprop]; - } - // finally try and get inline style - return el.style[cssprop]; - } - - /** - * Checks if a given element is statically positioned - * @param element - raw DOM element - */ - function isStaticPositioned(element) { - return (getStyle(element, 'position') || 'static') === 'static'; - } - - /** - * returns the closest, non-statically positioned parentOffset of a given element - * @param element - */ - var parentOffsetEl = function(element) { - var docDomEl = $document[0]; - var offsetParent = element.offsetParent || docDomEl; - while (offsetParent && offsetParent !== docDomEl && isStaticPositioned(offsetParent)) { - offsetParent = offsetParent.offsetParent; - } - return offsetParent || docDomEl; - }; - - return { - /** - * @ngdoc method - * @name $ionicPosition#position - * @description Get the current coordinates of the element, relative to the offset parent. - * Read-only equivalent of [jQuery's position function](http://api.jquery.com/position/). - * @param {element} element The element to get the position of. - * @returns {object} Returns an object containing the properties top, left, width and height. - */ - position: function(element) { - var elBCR = this.offset(element); - var offsetParentBCR = { top: 0, left: 0 }; - var offsetParentEl = parentOffsetEl(element[0]); - if (offsetParentEl != $document[0]) { - offsetParentBCR = this.offset(jqLite(offsetParentEl)); - offsetParentBCR.top += offsetParentEl.clientTop - offsetParentEl.scrollTop; - offsetParentBCR.left += offsetParentEl.clientLeft - offsetParentEl.scrollLeft; - } - - var boundingClientRect = element[0].getBoundingClientRect(); - return { - width: boundingClientRect.width || element.prop('offsetWidth'), - height: boundingClientRect.height || element.prop('offsetHeight'), - top: elBCR.top - offsetParentBCR.top, - left: elBCR.left - offsetParentBCR.left - }; - }, - - /** - * @ngdoc method - * @name $ionicPosition#offset - * @description Get the current coordinates of the element, relative to the document. - * Read-only equivalent of [jQuery's offset function](http://api.jquery.com/offset/). - * @param {element} element The element to get the offset of. - * @returns {object} Returns an object containing the properties top, left, width and height. - */ - offset: function(element) { - var boundingClientRect = element[0].getBoundingClientRect(); - return { - width: boundingClientRect.width || element.prop('offsetWidth'), - height: boundingClientRect.height || element.prop('offsetHeight'), - top: boundingClientRect.top + ($window.pageYOffset || $document[0].documentElement.scrollTop), - left: boundingClientRect.left + ($window.pageXOffset || $document[0].documentElement.scrollLeft) - }; - } - - }; -}]); - - -/** - * @ngdoc service - * @name $ionicScrollDelegate - * @module ionic - * @description - * Delegate for controlling scrollViews (created by - * {@link ionic.directive:ionContent} and - * {@link ionic.directive:ionScroll} directives). - * - * Methods called directly on the $ionicScrollDelegate service will control all scroll - * views. Use the {@link ionic.service:$ionicScrollDelegate#$getByHandle $getByHandle} - * method to control specific scrollViews. - * - * @usage - * - * ```html - * - * - * - * - * - * ``` - * ```js - * function MainCtrl($scope, $ionicScrollDelegate) { - * $scope.scrollTop = function() { - * $ionicScrollDelegate.scrollTop(); - * }; - * } - * ``` - * - * Example of advanced usage, with two scroll areas using `delegate-handle` - * for fine control. - * - * ```html - * - * - * - * - * - * - * - * - * ``` - * ```js - * function MainCtrl($scope, $ionicScrollDelegate) { - * $scope.scrollMainToTop = function() { - * $ionicScrollDelegate.$getByHandle('mainScroll').scrollTop(); - * }; - * $scope.scrollSmallToTop = function() { - * $ionicScrollDelegate.$getByHandle('small').scrollTop(); - * }; - * } - * ``` - */ -IonicModule -.service('$ionicScrollDelegate', ionic.DelegateService([ - /** - * @ngdoc method - * @name $ionicScrollDelegate#resize - * @description Tell the scrollView to recalculate the size of its container. - */ - 'resize', - /** - * @ngdoc method - * @name $ionicScrollDelegate#scrollTop - * @param {boolean=} shouldAnimate Whether the scroll should animate. - */ - 'scrollTop', - /** - * @ngdoc method - * @name $ionicScrollDelegate#scrollBottom - * @param {boolean=} shouldAnimate Whether the scroll should animate. - */ - 'scrollBottom', - /** - * @ngdoc method - * @name $ionicScrollDelegate#scrollTo - * @param {number} left The x-value to scroll to. - * @param {number} top The y-value to scroll to. - * @param {boolean=} shouldAnimate Whether the scroll should animate. - */ - 'scrollTo', - /** - * @ngdoc method - * @name $ionicScrollDelegate#scrollBy - * @param {number} left The x-offset to scroll by. - * @param {number} top The y-offset to scroll by. - * @param {boolean=} shouldAnimate Whether the scroll should animate. - */ - 'scrollBy', - /** - * @ngdoc method - * @name $ionicScrollDelegate#zoomTo - * @param {number} level Level to zoom to. - * @param {boolean=} animate Whether to animate the zoom. - * @param {number=} originLeft Zoom in at given left coordinate. - * @param {number=} originTop Zoom in at given top coordinate. - */ - 'zoomTo', - /** - * @ngdoc method - * @name $ionicScrollDelegate#zoomBy - * @param {number} factor The factor to zoom by. - * @param {boolean=} animate Whether to animate the zoom. - * @param {number=} originLeft Zoom in at given left coordinate. - * @param {number=} originTop Zoom in at given top coordinate. - */ - 'zoomBy', - /** - * @ngdoc method - * @name $ionicScrollDelegate#getScrollPosition - * @returns {object} The scroll position of this view, with the following properties: - * - `{number}` `left` The distance the user has scrolled from the left (starts at 0). - * - `{number}` `top` The distance the user has scrolled from the top (starts at 0). - * - `{number}` `zoom` The current zoom level. - */ - 'getScrollPosition', - /** - * @ngdoc method - * @name $ionicScrollDelegate#anchorScroll - * @description Tell the scrollView to scroll to the element with an id - * matching window.location.hash. - * - * If no matching element is found, it will scroll to top. - * - * @param {boolean=} shouldAnimate Whether the scroll should animate. - */ - 'anchorScroll', - /** - * @ngdoc method - * @name $ionicScrollDelegate#freezeScroll - * @description Does not allow this scroll view to scroll either x or y. - * @param {boolean=} shouldFreeze Should this scroll view be prevented from scrolling or not. - * @returns {boolean} If the scroll view is being prevented from scrolling or not. - */ - 'freezeScroll', - /** - * @ngdoc method - * @name $ionicScrollDelegate#freezeAllScrolls - * @description Does not allow any of the app's scroll views to scroll either x or y. - * @param {boolean=} shouldFreeze Should all app scrolls be prevented from scrolling or not. - */ - 'freezeAllScrolls', - /** - * @ngdoc method - * @name $ionicScrollDelegate#getScrollView - * @returns {object} The scrollView associated with this delegate. - */ - 'getScrollView' - /** - * @ngdoc method - * @name $ionicScrollDelegate#$getByHandle - * @param {string} handle - * @returns `delegateInstance` A delegate instance that controls only the - * scrollViews with `delegate-handle` matching the given handle. - * - * Example: `$ionicScrollDelegate.$getByHandle('my-handle').scrollTop();` - */ -])); - - -/** - * @ngdoc service - * @name $ionicSideMenuDelegate - * @module ionic - * - * @description - * Delegate for controlling the {@link ionic.directive:ionSideMenus} directive. - * - * Methods called directly on the $ionicSideMenuDelegate service will control all side - * menus. Use the {@link ionic.service:$ionicSideMenuDelegate#$getByHandle $getByHandle} - * method to control specific ionSideMenus instances. - * - * @usage - * - * ```html - * - * - * - * Content! - * - * - * - * Left Menu! - * - * - * - * ``` - * ```js - * function MainCtrl($scope, $ionicSideMenuDelegate) { - * $scope.toggleLeftSideMenu = function() { - * $ionicSideMenuDelegate.toggleLeft(); - * }; - * } - * ``` - */ -IonicModule -.service('$ionicSideMenuDelegate', ionic.DelegateService([ - /** - * @ngdoc method - * @name $ionicSideMenuDelegate#toggleLeft - * @description Toggle the left side menu (if it exists). - * @param {boolean=} isOpen Whether to open or close the menu. - * Default: Toggles the menu. - */ - 'toggleLeft', - /** - * @ngdoc method - * @name $ionicSideMenuDelegate#toggleRight - * @description Toggle the right side menu (if it exists). - * @param {boolean=} isOpen Whether to open or close the menu. - * Default: Toggles the menu. - */ - 'toggleRight', - /** - * @ngdoc method - * @name $ionicSideMenuDelegate#getOpenRatio - * @description Gets the ratio of open amount over menu width. For example, a - * menu of width 100 that is opened by 50 pixels is 50% opened, and would return - * a ratio of 0.5. - * - * @returns {float} 0 if nothing is open, between 0 and 1 if left menu is - * opened/opening, and between 0 and -1 if right menu is opened/opening. - */ - 'getOpenRatio', - /** - * @ngdoc method - * @name $ionicSideMenuDelegate#isOpen - * @returns {boolean} Whether either the left or right menu is currently opened. - */ - 'isOpen', - /** - * @ngdoc method - * @name $ionicSideMenuDelegate#isOpenLeft - * @returns {boolean} Whether the left menu is currently opened. - */ - 'isOpenLeft', - /** - * @ngdoc method - * @name $ionicSideMenuDelegate#isOpenRight - * @returns {boolean} Whether the right menu is currently opened. - */ - 'isOpenRight', - /** - * @ngdoc method - * @name $ionicSideMenuDelegate#canDragContent - * @param {boolean=} canDrag Set whether the content can or cannot be dragged to open - * side menus. - * @returns {boolean} Whether the content can be dragged to open side menus. - */ - 'canDragContent', - /** - * @ngdoc method - * @name $ionicSideMenuDelegate#edgeDragThreshold - * @param {boolean|number=} value Set whether the content drag can only start if it is below a certain threshold distance from the edge of the screen. Accepts three different values: - * - If a non-zero number is given, that many pixels is used as the maximum allowed distance from the edge that starts dragging the side menu. - * - If true is given, the default number of pixels (25) is used as the maximum allowed distance. - * - If false or 0 is given, the edge drag threshold is disabled, and dragging from anywhere on the content is allowed. - * @returns {boolean} Whether the drag can start only from within the edge of screen threshold. - */ - 'edgeDragThreshold' - /** - * @ngdoc method - * @name $ionicSideMenuDelegate#$getByHandle - * @param {string} handle - * @returns `delegateInstance` A delegate instance that controls only the - * {@link ionic.directive:ionSideMenus} directives with `delegate-handle` matching - * the given handle. - * - * Example: `$ionicSideMenuDelegate.$getByHandle('my-handle').toggleLeft();` - */ -])); - - -/** - * @ngdoc service - * @name $ionicSlideBoxDelegate - * @module ionic - * @description - * Delegate that controls the {@link ionic.directive:ionSlideBox} directive. - * - * Methods called directly on the $ionicSlideBoxDelegate service will control all slide boxes. Use the {@link ionic.service:$ionicSlideBoxDelegate#$getByHandle $getByHandle} - * method to control specific slide box instances. - * - * @usage - * - * ```html - * - * - * - *
    - * - *
    - *
    - * - *
    - * Slide 2! - *
    - *
    - *
    - *
    - * ``` - * ```js - * function MyCtrl($scope, $ionicSlideBoxDelegate) { - * $scope.nextSlide = function() { - * $ionicSlideBoxDelegate.next(); - * } - * } - * ``` - */ -IonicModule -.service('$ionicSlideBoxDelegate', ionic.DelegateService([ - /** - * @ngdoc method - * @name $ionicSlideBoxDelegate#update - * @description - * Update the slidebox (for example if using Angular with ng-repeat, - * resize it for the elements inside). - */ - 'update', - /** - * @ngdoc method - * @name $ionicSlideBoxDelegate#slide - * @param {number} to The index to slide to. - * @param {number=} speed The number of milliseconds the change should take. - */ - 'slide', - 'select', - /** - * @ngdoc method - * @name $ionicSlideBoxDelegate#enableSlide - * @param {boolean=} shouldEnable Whether to enable sliding the slidebox. - * @returns {boolean} Whether sliding is enabled. - */ - 'enableSlide', - /** - * @ngdoc method - * @name $ionicSlideBoxDelegate#previous - * @param {number=} speed The number of milliseconds the change should take. - * @description Go to the previous slide. Wraps around if at the beginning. - */ - 'previous', - /** - * @ngdoc method - * @name $ionicSlideBoxDelegate#next - * @param {number=} speed The number of milliseconds the change should take. - * @description Go to the next slide. Wraps around if at the end. - */ - 'next', - /** - * @ngdoc method - * @name $ionicSlideBoxDelegate#stop - * @description Stop sliding. The slideBox will not move again until - * explicitly told to do so. - */ - 'stop', - 'autoPlay', - /** - * @ngdoc method - * @name $ionicSlideBoxDelegate#start - * @description Start sliding again if the slideBox was stopped. - */ - 'start', - /** - * @ngdoc method - * @name $ionicSlideBoxDelegate#currentIndex - * @returns number The index of the current slide. - */ - 'currentIndex', - 'selected', - /** - * @ngdoc method - * @name $ionicSlideBoxDelegate#slidesCount - * @returns number The number of slides there are currently. - */ - 'slidesCount', - 'count', - 'loop' - /** - * @ngdoc method - * @name $ionicSlideBoxDelegate#$getByHandle - * @param {string} handle - * @returns `delegateInstance` A delegate instance that controls only the - * {@link ionic.directive:ionSlideBox} directives with `delegate-handle` matching - * the given handle. - * - * Example: `$ionicSlideBoxDelegate.$getByHandle('my-handle').stop();` - */ -])); - - -/** - * @ngdoc service - * @name $ionicTabsDelegate - * @module ionic - * - * @description - * Delegate for controlling the {@link ionic.directive:ionTabs} directive. - * - * Methods called directly on the $ionicTabsDelegate service will control all ionTabs - * directives. Use the {@link ionic.service:$ionicTabsDelegate#$getByHandle $getByHandle} - * method to control specific ionTabs instances. - * - * @usage - * - * ```html - * - * - * - * - * Hello tab 1! - * - * - * Hello tab 2! - * - * - * - * ``` - * ```js - * function MyCtrl($scope, $ionicTabsDelegate) { - * $scope.selectTabWithIndex = function(index) { - * $ionicTabsDelegate.select(index); - * } - * } - * ``` - */ -IonicModule -.service('$ionicTabsDelegate', ionic.DelegateService([ - /** - * @ngdoc method - * @name $ionicTabsDelegate#select - * @description Select the tab matching the given index. - * - * @param {number} index Index of the tab to select. - */ - 'select', - /** - * @ngdoc method - * @name $ionicTabsDelegate#selectedIndex - * @returns `number` The index of the selected tab, or -1. - */ - 'selectedIndex', - /** - * @ngdoc method - * @name $ionicTabsDelegate#showBar - * @description - * Set/get whether the {@link ionic.directive:ionTabs} is shown - * @param {boolean} show Whether to show the bar. - * @returns {boolean} Whether the bar is shown. - */ - 'showBar' - /** - * @ngdoc method - * @name $ionicTabsDelegate#$getByHandle - * @param {string} handle - * @returns `delegateInstance` A delegate instance that controls only the - * {@link ionic.directive:ionTabs} directives with `delegate-handle` matching - * the given handle. - * - * Example: `$ionicTabsDelegate.$getByHandle('my-handle').select(0);` - */ -])); - -// closure to keep things neat -(function() { - var templatesToCache = []; - -/** - * @ngdoc service - * @name $ionicTemplateCache - * @module ionic - * @description A service that preemptively caches template files to eliminate transition flicker and boost performance. - * @usage - * State templates are cached automatically, but you can optionally cache other templates. - * - * ```js - * $ionicTemplateCache('myNgIncludeTemplate.html'); - * ``` - * - * Optionally disable all preemptive caching with the `$ionicConfigProvider` or individual states by setting `prefetchTemplate` - * in the `$state` definition - * - * ```js - * angular.module('myApp', ['ionic']) - * .config(function($stateProvider, $ionicConfigProvider) { - * - * // disable preemptive template caching globally - * $ionicConfigProvider.templates.prefetch(false); - * - * // disable individual states - * $stateProvider - * .state('tabs', { - * url: "/tab", - * abstract: true, - * prefetchTemplate: false, - * templateUrl: "tabs-templates/tabs.html" - * }) - * .state('tabs.home', { - * url: "/home", - * views: { - * 'home-tab': { - * prefetchTemplate: false, - * templateUrl: "tabs-templates/home.html", - * controller: 'HomeTabCtrl' - * } - * } - * }); - * }); - * ``` - */ -IonicModule -.factory('$ionicTemplateCache', [ -'$http', -'$templateCache', -'$timeout', -function($http, $templateCache, $timeout) { - var toCache = templatesToCache, - hasRun; - - function $ionicTemplateCache(templates) { - if (typeof templates === 'undefined') { - return run(); - } - if (isString(templates)) { - templates = [templates]; - } - forEach(templates, function(template) { - toCache.push(template); - }); - if (hasRun) { - run(); - } - } - - // run through methods - internal method - function run() { - var template; - $ionicTemplateCache._runCount++; - - hasRun = true; - // ignore if race condition already zeroed out array - if (toCache.length === 0) return; - - var i = 0; - while (i < 4 && (template = toCache.pop())) { - // note that inline templates are ignored by this request - if (isString(template)) $http.get(template, { cache: $templateCache }); - i++; - } - // only preload 3 templates a second - if (toCache.length) { - $timeout(run, 1000); - } - } - - // exposing for testing - $ionicTemplateCache._runCount = 0; - // default method - return $ionicTemplateCache; -}]) - -// Intercepts the $stateprovider.state() command to look for templateUrls that can be cached -.config([ -'$stateProvider', -'$ionicConfigProvider', -function($stateProvider, $ionicConfigProvider) { - var stateProviderState = $stateProvider.state; - $stateProvider.state = function(stateName, definition) { - // don't even bother if it's disabled. note, another config may run after this, so it's not a catch-all - if (typeof definition === 'object') { - var enabled = definition.prefetchTemplate !== false && templatesToCache.length < $ionicConfigProvider.templates.maxPrefetch(); - if (enabled && isString(definition.templateUrl)) templatesToCache.push(definition.templateUrl); - if (angular.isObject(definition.views)) { - for (var key in definition.views) { - enabled = definition.views[key].prefetchTemplate !== false && templatesToCache.length < $ionicConfigProvider.templates.maxPrefetch(); - if (enabled && isString(definition.views[key].templateUrl)) templatesToCache.push(definition.views[key].templateUrl); - } - } - } - return stateProviderState.call($stateProvider, stateName, definition); - }; -}]) - -// process the templateUrls collected by the $stateProvider, adding them to the cache -.run(['$ionicTemplateCache', function($ionicTemplateCache) { - $ionicTemplateCache(); -}]); - -})(); - -IonicModule -.factory('$ionicTemplateLoader', [ - '$compile', - '$controller', - '$http', - '$q', - '$rootScope', - '$templateCache', -function($compile, $controller, $http, $q, $rootScope, $templateCache) { - - return { - load: fetchTemplate, - compile: loadAndCompile - }; - - function fetchTemplate(url) { - return $http.get(url, {cache: $templateCache}) - .then(function(response) { - return response.data && response.data.trim(); - }); - } - - function loadAndCompile(options) { - options = extend({ - template: '', - templateUrl: '', - scope: null, - controller: null, - locals: {}, - appendTo: null - }, options || {}); - - var templatePromise = options.templateUrl ? - this.load(options.templateUrl) : - $q.when(options.template); - - return templatePromise.then(function(template) { - var controller; - var scope = options.scope || $rootScope.$new(); - - //Incase template doesn't have just one root element, do this - var element = jqLite('
    ').html(template).contents(); - - if (options.controller) { - controller = $controller( - options.controller, - extend(options.locals, { - $scope: scope - }) - ); - element.children().data('$ngControllerController', controller); - } - if (options.appendTo) { - jqLite(options.appendTo).append(element); - } - - $compile(element)(scope); - - return { - element: element, - scope: scope - }; - }); - } - -}]); - -/** - * @private - * DEPRECATED, as of v1.0.0-beta14 ------- - */ -IonicModule -.factory('$ionicViewService', ['$ionicHistory', '$log', function($ionicHistory, $log) { - - function warn(oldMethod, newMethod) { - $log.warn('$ionicViewService' + oldMethod + ' is deprecated, please use $ionicHistory' + newMethod + ' instead: http://ionicframework.com/docs/nightly/api/service/$ionicHistory/'); - } - - warn('', ''); - - var methodsMap = { - getCurrentView: 'currentView', - getBackView: 'backView', - getForwardView: 'forwardView', - getCurrentStateName: 'currentStateName', - nextViewOptions: 'nextViewOptions', - clearHistory: 'clearHistory' - }; - - forEach(methodsMap, function(newMethod, oldMethod) { - methodsMap[oldMethod] = function() { - warn('.' + oldMethod, '.' + newMethod); - return $ionicHistory[newMethod].apply(this, arguments); - }; - }); - - return methodsMap; - -}]); - -/** - * @private - * TODO document - */ - -IonicModule.factory('$ionicViewSwitcher', [ - '$timeout', - '$document', - '$q', - '$ionicClickBlock', - '$ionicConfig', - '$ionicNavBarDelegate', -function($timeout, $document, $q, $ionicClickBlock, $ionicConfig, $ionicNavBarDelegate) { - - var TRANSITIONEND_EVENT = 'webkitTransitionEnd transitionend'; - var DATA_NO_CACHE = '$noCache'; - var DATA_DESTROY_ELE = '$destroyEle'; - var DATA_ELE_IDENTIFIER = '$eleId'; - var DATA_VIEW_ACCESSED = '$accessed'; - var DATA_FALLBACK_TIMER = '$fallbackTimer'; - var DATA_VIEW = '$viewData'; - var NAV_VIEW_ATTR = 'nav-view'; - var VIEW_STATUS_ACTIVE = 'active'; - var VIEW_STATUS_CACHED = 'cached'; - var VIEW_STATUS_STAGED = 'stage'; - - var transitionCounter = 0; - var nextTransition, nextDirection; - ionic.transition = ionic.transition || {}; - ionic.transition.isActive = false; - var isActiveTimer; - var cachedAttr = ionic.DomUtil.cachedAttr; - var transitionPromises = []; - var defaultTimeout = 1100; - - var ionicViewSwitcher = { - - create: function(navViewCtrl, viewLocals, enteringView, leavingView, renderStart, renderEnd) { - // get a reference to an entering/leaving element if they exist - // loop through to see if the view is already in the navViewElement - var enteringEle, leavingEle; - var transitionId = ++transitionCounter; - var alreadyInDom; - - var switcher = { - - init: function(registerData, callback) { - ionicViewSwitcher.isTransitioning(true); - - switcher.loadViewElements(registerData); - - switcher.render(registerData, function() { - callback && callback(); - }); - }, - - loadViewElements: function(registerData) { - var x, l, viewEle; - var viewElements = navViewCtrl.getViewElements(); - var enteringEleIdentifier = getViewElementIdentifier(viewLocals, enteringView); - var navViewActiveEleId = navViewCtrl.activeEleId(); - - for (x = 0, l = viewElements.length; x < l; x++) { - viewEle = viewElements.eq(x); - - if (viewEle.data(DATA_ELE_IDENTIFIER) === enteringEleIdentifier) { - // we found an existing element in the DOM that should be entering the view - if (viewEle.data(DATA_NO_CACHE)) { - // the existing element should not be cached, don't use it - viewEle.data(DATA_ELE_IDENTIFIER, enteringEleIdentifier + ionic.Utils.nextUid()); - viewEle.data(DATA_DESTROY_ELE, true); - - } else { - enteringEle = viewEle; - } - - } else if (isDefined(navViewActiveEleId) && viewEle.data(DATA_ELE_IDENTIFIER) === navViewActiveEleId) { - leavingEle = viewEle; - } - - if (enteringEle && leavingEle) break; - } - - alreadyInDom = !!enteringEle; - - if (!alreadyInDom) { - // still no existing element to use - // create it using existing template/scope/locals - enteringEle = registerData.ele || ionicViewSwitcher.createViewEle(viewLocals); - - // existing elements in the DOM are looked up by their state name and state id - enteringEle.data(DATA_ELE_IDENTIFIER, enteringEleIdentifier); - } - - if (renderEnd) { - navViewCtrl.activeEleId(enteringEleIdentifier); - } - - registerData.ele = null; - }, - - render: function(registerData, callback) { - if (alreadyInDom) { - // it was already found in the DOM, just reconnect the scope - ionic.Utils.reconnectScope(enteringEle.scope()); - - } else { - // the entering element is not already in the DOM - // set that the entering element should be "staged" and its - // styles of where this element will go before it hits the DOM - navViewAttr(enteringEle, VIEW_STATUS_STAGED); - - var enteringData = getTransitionData(viewLocals, enteringEle, registerData.direction, enteringView); - var transitionFn = $ionicConfig.transitions.views[enteringData.transition] || $ionicConfig.transitions.views.none; - transitionFn(enteringEle, null, enteringData.direction, true).run(0); - - enteringEle.data(DATA_VIEW, { - viewId: enteringData.viewId, - historyId: enteringData.historyId, - stateName: enteringData.stateName, - stateParams: enteringData.stateParams - }); - - // if the current state has cache:false - // or the element has cache-view="false" attribute - if (viewState(viewLocals).cache === false || viewState(viewLocals).cache === 'false' || - enteringEle.attr('cache-view') == 'false' || $ionicConfig.views.maxCache() === 0) { - enteringEle.data(DATA_NO_CACHE, true); - } - - // append the entering element to the DOM, create a new scope and run link - var viewScope = navViewCtrl.appendViewElement(enteringEle, viewLocals); - - delete enteringData.direction; - delete enteringData.transition; - viewScope.$emit('$ionicView.loaded', enteringData); - } - - // update that this view was just accessed - enteringEle.data(DATA_VIEW_ACCESSED, Date.now()); - - callback && callback(); - }, - - transition: function(direction, enableBack, allowAnimate) { - var deferred; - var enteringData = getTransitionData(viewLocals, enteringEle, direction, enteringView); - var leavingData = extend(extend({}, enteringData), getViewData(leavingView)); - enteringData.transitionId = leavingData.transitionId = transitionId; - enteringData.fromCache = !!alreadyInDom; - enteringData.enableBack = !!enableBack; - enteringData.renderStart = renderStart; - enteringData.renderEnd = renderEnd; - - cachedAttr(enteringEle.parent(), 'nav-view-transition', enteringData.transition); - cachedAttr(enteringEle.parent(), 'nav-view-direction', enteringData.direction); - - // cancel any previous transition complete fallbacks - $timeout.cancel(enteringEle.data(DATA_FALLBACK_TIMER)); - - // get the transition ready and see if it'll animate - var transitionFn = $ionicConfig.transitions.views[enteringData.transition] || $ionicConfig.transitions.views.none; - var viewTransition = transitionFn(enteringEle, leavingEle, enteringData.direction, - enteringData.shouldAnimate && allowAnimate && renderEnd); - - if (viewTransition.shouldAnimate) { - // attach transitionend events (and fallback timer) - enteringEle.on(TRANSITIONEND_EVENT, completeOnTransitionEnd); - enteringEle.data(DATA_FALLBACK_TIMER, $timeout(transitionComplete, defaultTimeout)); - $ionicClickBlock.show(defaultTimeout); - } - - if (renderStart) { - // notify the views "before" the transition starts - switcher.emit('before', enteringData, leavingData); - - // stage entering element, opacity 0, no transition duration - navViewAttr(enteringEle, VIEW_STATUS_STAGED); - - // render the elements in the correct location for their starting point - viewTransition.run(0); - } - - if (renderEnd) { - // create a promise so we can keep track of when all transitions finish - // only required if this transition should complete - deferred = $q.defer(); - transitionPromises.push(deferred.promise); - } - - if (renderStart && renderEnd) { - // CSS "auto" transitioned, not manually transitioned - // wait a frame so the styles apply before auto transitioning - $timeout(function() { - ionic.requestAnimationFrame(onReflow); - }); - } else if (!renderEnd) { - // just the start of a manual transition - // but it will not render the end of the transition - navViewAttr(enteringEle, 'entering'); - navViewAttr(leavingEle, 'leaving'); - - // return the transition run method so each step can be ran manually - return { - run: viewTransition.run, - cancel: function(shouldAnimate) { - if (shouldAnimate) { - enteringEle.on(TRANSITIONEND_EVENT, cancelOnTransitionEnd); - enteringEle.data(DATA_FALLBACK_TIMER, $timeout(cancelTransition, defaultTimeout)); - $ionicClickBlock.show(defaultTimeout); - } else { - cancelTransition(); - } - viewTransition.shouldAnimate = shouldAnimate; - viewTransition.run(0); - viewTransition = null; - } - }; - - } else if (renderEnd) { - // just the end of a manual transition - // happens after the manual transition has completed - // and a full history change has happened - onReflow(); - } - - - function onReflow() { - // remove that we're staging the entering element so it can auto transition - navViewAttr(enteringEle, viewTransition.shouldAnimate ? 'entering' : VIEW_STATUS_ACTIVE); - navViewAttr(leavingEle, viewTransition.shouldAnimate ? 'leaving' : VIEW_STATUS_CACHED); - - // start the auto transition and let the CSS take over - viewTransition.run(1); - - // trigger auto transitions on the associated nav bars - $ionicNavBarDelegate._instances.forEach(function(instance) { - instance.triggerTransitionStart(transitionId); - }); - - if (!viewTransition.shouldAnimate) { - // no animated auto transition - transitionComplete(); - } - } - - // Make sure that transitionend events bubbling up from children won't fire - // transitionComplete. Will only go forward if ev.target == the element listening. - function completeOnTransitionEnd(ev) { - if (ev.target !== this) return; - transitionComplete(); - } - function transitionComplete() { - if (transitionComplete.x) return; - transitionComplete.x = true; - - enteringEle.off(TRANSITIONEND_EVENT, completeOnTransitionEnd); - $timeout.cancel(enteringEle.data(DATA_FALLBACK_TIMER)); - leavingEle && $timeout.cancel(leavingEle.data(DATA_FALLBACK_TIMER)); - - // resolve that this one transition (there could be many w/ nested views) - deferred && deferred.resolve(navViewCtrl); - - // the most recent transition added has completed and all the active - // transition promises should be added to the services array of promises - if (transitionId === transitionCounter) { - $q.all(transitionPromises).then(ionicViewSwitcher.transitionEnd); - - // emit that the views have finished transitioning - // each parent nav-view will update which views are active and cached - switcher.emit('after', enteringData, leavingData); - switcher.cleanup(enteringData); - } - - // tell the nav bars that the transition has ended - $ionicNavBarDelegate._instances.forEach(function(instance) { - instance.triggerTransitionEnd(); - }); - - - // remove any references that could cause memory issues - nextTransition = nextDirection = enteringView = leavingView = enteringEle = leavingEle = null; - } - - // Make sure that transitionend events bubbling up from children won't fire - // transitionComplete. Will only go forward if ev.target == the element listening. - function cancelOnTransitionEnd(ev) { - if (ev.target !== this) return; - cancelTransition(); - } - function cancelTransition() { - navViewAttr(enteringEle, VIEW_STATUS_CACHED); - navViewAttr(leavingEle, VIEW_STATUS_ACTIVE); - enteringEle.off(TRANSITIONEND_EVENT, cancelOnTransitionEnd); - $timeout.cancel(enteringEle.data(DATA_FALLBACK_TIMER)); - ionicViewSwitcher.transitionEnd([navViewCtrl]); - } - - }, - - emit: function(step, enteringData, leavingData) { - var enteringScope = getScopeForElement(enteringEle, enteringData); - var leavingScope = getScopeForElement(leavingEle, leavingData); - - var prefixesAreEqual; - - if ( !enteringData.viewId || enteringData.abstractView ) { - // it's an abstract view, so treat it accordingly - - // we only get access to the leaving scope once in the transition, - // so dispatch all events right away if it exists - if ( leavingScope ) { - leavingScope.$emit('$ionicView.beforeLeave', leavingData); - leavingScope.$emit('$ionicView.leave', leavingData); - leavingScope.$emit('$ionicView.afterLeave', leavingData); - leavingScope.$broadcast('$ionicParentView.beforeLeave', leavingData); - leavingScope.$broadcast('$ionicParentView.leave', leavingData); - leavingScope.$broadcast('$ionicParentView.afterLeave', leavingData); - } - } - else { - // it's a regular view, so do the normal process - if (step == 'after') { - if (enteringScope) { - enteringScope.$emit('$ionicView.enter', enteringData); - enteringScope.$broadcast('$ionicParentView.enter', enteringData); - } - - if (leavingScope) { - leavingScope.$emit('$ionicView.leave', leavingData); - leavingScope.$broadcast('$ionicParentView.leave', leavingData); - } - else if (enteringScope && leavingData && leavingData.viewId && enteringData.stateName !== leavingData.stateName) { - // we only want to dispatch this when we are doing a single-tier - // state change such as changing a tab, so compare the state - // for the same state-prefix but different suffix - prefixesAreEqual = compareStatePrefixes(enteringData.stateName, leavingData.stateName); - if ( prefixesAreEqual ) { - enteringScope.$emit('$ionicNavView.leave', leavingData); - } - } - } - - if (enteringScope) { - enteringScope.$emit('$ionicView.' + step + 'Enter', enteringData); - enteringScope.$broadcast('$ionicParentView.' + step + 'Enter', enteringData); - } - - if (leavingScope) { - leavingScope.$emit('$ionicView.' + step + 'Leave', leavingData); - leavingScope.$broadcast('$ionicParentView.' + step + 'Leave', leavingData); - - } else if (enteringScope && leavingData && leavingData.viewId && enteringData.stateName !== leavingData.stateName) { - // we only want to dispatch this when we are doing a single-tier - // state change such as changing a tab, so compare the state - // for the same state-prefix but different suffix - prefixesAreEqual = compareStatePrefixes(enteringData.stateName, leavingData.stateName); - if ( prefixesAreEqual ) { - enteringScope.$emit('$ionicNavView.' + step + 'Leave', leavingData); - } - } - } - }, - - cleanup: function(transData) { - // check if any views should be removed - if (leavingEle && transData.direction == 'back' && !$ionicConfig.views.forwardCache()) { - // if they just navigated back we can destroy the forward view - // do not remove forward views if cacheForwardViews config is true - destroyViewEle(leavingEle); - } - - var viewElements = navViewCtrl.getViewElements(); - var viewElementsLength = viewElements.length; - var x, viewElement; - var removeOldestAccess = (viewElementsLength - 1) > $ionicConfig.views.maxCache(); - var removableEle; - var oldestAccess = Date.now(); - - for (x = 0; x < viewElementsLength; x++) { - viewElement = viewElements.eq(x); - - if (removeOldestAccess && viewElement.data(DATA_VIEW_ACCESSED) < oldestAccess) { - // remember what was the oldest element to be accessed so it can be destroyed - oldestAccess = viewElement.data(DATA_VIEW_ACCESSED); - removableEle = viewElements.eq(x); - - } else if (viewElement.data(DATA_DESTROY_ELE) && navViewAttr(viewElement) != VIEW_STATUS_ACTIVE) { - destroyViewEle(viewElement); - } - } - - destroyViewEle(removableEle); - - if (enteringEle.data(DATA_NO_CACHE)) { - enteringEle.data(DATA_DESTROY_ELE, true); - } - }, - - enteringEle: function() { return enteringEle; }, - leavingEle: function() { return leavingEle; } - - }; - - return switcher; - }, - - transitionEnd: function(navViewCtrls) { - forEach(navViewCtrls, function(navViewCtrl) { - navViewCtrl.transitionEnd(); - }); - - ionicViewSwitcher.isTransitioning(false); - $ionicClickBlock.hide(); - transitionPromises = []; - }, - - nextTransition: function(val) { - nextTransition = val; - }, - - nextDirection: function(val) { - nextDirection = val; - }, - - isTransitioning: function(val) { - if (arguments.length) { - ionic.transition.isActive = !!val; - $timeout.cancel(isActiveTimer); - if (val) { - isActiveTimer = $timeout(function() { - ionicViewSwitcher.isTransitioning(false); - }, 999); - } - } - return ionic.transition.isActive; - }, - - createViewEle: function(viewLocals) { - var containerEle = $document[0].createElement('div'); - if (viewLocals && viewLocals.$template) { - containerEle.innerHTML = viewLocals.$template; - if (containerEle.children.length === 1) { - containerEle.children[0].classList.add('pane'); - if ( viewLocals.$$state && viewLocals.$$state.self && viewLocals.$$state.self['abstract'] ) { - angular.element(containerEle.children[0]).attr("abstract", "true"); - } - else { - if ( viewLocals.$$state && viewLocals.$$state.self ) { - angular.element(containerEle.children[0]).attr("state", viewLocals.$$state.self.name); - } - - } - return jqLite(containerEle.children[0]); - } - } - containerEle.className = "pane"; - return jqLite(containerEle); - }, - - viewEleIsActive: function(viewEle, isActiveAttr) { - navViewAttr(viewEle, isActiveAttr ? VIEW_STATUS_ACTIVE : VIEW_STATUS_CACHED); - }, - - getTransitionData: getTransitionData, - navViewAttr: navViewAttr, - destroyViewEle: destroyViewEle - - }; - - return ionicViewSwitcher; - - - function getViewElementIdentifier(locals, view) { - if (viewState(locals)['abstract']) return viewState(locals).name; - if (view) return view.stateId || view.viewId; - return ionic.Utils.nextUid(); - } - - function viewState(locals) { - return locals && locals.$$state && locals.$$state.self || {}; - } - - function getTransitionData(viewLocals, enteringEle, direction, view) { - // Priority - // 1) attribute directive on the button/link to this view - // 2) entering element's attribute - // 3) entering view's $state config property - // 4) view registration data - // 5) global config - // 6) fallback value - - var state = viewState(viewLocals); - var viewTransition = nextTransition || cachedAttr(enteringEle, 'view-transition') || state.viewTransition || $ionicConfig.views.transition() || 'ios'; - var navBarTransition = $ionicConfig.navBar.transition(); - direction = nextDirection || cachedAttr(enteringEle, 'view-direction') || state.viewDirection || direction || 'none'; - - return extend(getViewData(view), { - transition: viewTransition, - navBarTransition: navBarTransition === 'view' ? viewTransition : navBarTransition, - direction: direction, - shouldAnimate: (viewTransition !== 'none' && direction !== 'none') - }); - } - - function getViewData(view) { - view = view || {}; - return { - viewId: view.viewId, - historyId: view.historyId, - stateId: view.stateId, - stateName: view.stateName, - stateParams: view.stateParams - }; - } - - function navViewAttr(ele, value) { - if (arguments.length > 1) { - cachedAttr(ele, NAV_VIEW_ATTR, value); - } else { - return cachedAttr(ele, NAV_VIEW_ATTR); - } - } - - function destroyViewEle(ele) { - // we found an element that should be removed - // destroy its scope, then remove the element - if (ele && ele.length) { - var viewScope = ele.scope(); - if (viewScope) { - viewScope.$emit('$ionicView.unloaded', ele.data(DATA_VIEW)); - viewScope.$destroy(); - } - ele.remove(); - } - } - - function compareStatePrefixes(enteringStateName, exitingStateName) { - var enteringStateSuffixIndex = enteringStateName.lastIndexOf('.'); - var exitingStateSuffixIndex = exitingStateName.lastIndexOf('.'); - - // if either of the prefixes are empty, just return false - if ( enteringStateSuffixIndex < 0 || exitingStateSuffixIndex < 0 ) { - return false; - } - - var enteringPrefix = enteringStateName.substring(0, enteringStateSuffixIndex); - var exitingPrefix = exitingStateName.substring(0, exitingStateSuffixIndex); - - return enteringPrefix === exitingPrefix; - } - - function getScopeForElement(element, stateData) { - if ( !element ) { - return null; - } - // check if it's abstract - var attributeValue = angular.element(element).attr("abstract"); - var stateValue = angular.element(element).attr("state"); - - if ( attributeValue !== "true" ) { - // it's not an abstract view, so make sure the element - // matches the state. Due to abstract view weirdness, - // sometimes it doesn't. If it doesn't, don't dispatch events - // so leave the scope undefined - if ( stateValue === stateData.stateName ) { - return angular.element(element).scope(); - } - return null; - } - else { - // it is an abstract element, so look for element with the "state" attributeValue - // set to the name of the stateData state - var elements = aggregateNavViewChildren(element); - for ( var i = 0; i < elements.length; i++ ) { - var state = angular.element(elements[i]).attr("state"); - if ( state === stateData.stateName ) { - stateData.abstractView = true; - return angular.element(elements[i]).scope(); - } - } - // we didn't find a match, so return null - return null; - } - } - - function aggregateNavViewChildren(element) { - var aggregate = []; - var navViews = angular.element(element).find("ion-nav-view"); - for ( var i = 0; i < navViews.length; i++ ) { - var children = angular.element(navViews[i]).children(); - var childrenAggregated = []; - for ( var j = 0; j < children.length; j++ ) { - childrenAggregated = childrenAggregated.concat(children[j]); - } - aggregate = aggregate.concat(childrenAggregated); - } - return aggregate; - } - -}]); - -/** - * ================== angular-ios9-uiwebview.patch.js v1.1.1 ================== - * - * This patch works around iOS9 UIWebView regression that causes infinite digest - * errors in Angular. - * - * The patch can be applied to Angular 1.2.0 ā€“ 1.4.5. Newer versions of Angular - * have the workaround baked in. - * - * To apply this patch load/bundle this file with your application and add a - * dependency on the "ngIOS9UIWebViewPatch" module to your main app module. - * - * For example: - * - * ``` - * angular.module('myApp', ['ngRoute'])` - * ``` - * - * becomes - * - * ``` - * angular.module('myApp', ['ngRoute', 'ngIOS9UIWebViewPatch']) - * ``` - * - * - * More info: - * - https://openradar.appspot.com/22186109 - * - https://github.com/angular/angular.js/issues/12241 - * - https://github.com/driftyco/ionic/issues/4082 - * - * - * @license AngularJS - * (c) 2010-2015 Google, Inc. http://angularjs.org - * License: MIT - */ - -angular.module('ngIOS9UIWebViewPatch', ['ng']).config(['$provide', function($provide) { - 'use strict'; - - $provide.decorator('$browser', ['$delegate', '$window', function($delegate, $window) { - - if (isIOS9UIWebView($window.navigator.userAgent)) { - return applyIOS9Shim($delegate); - } - - return $delegate; - - function isIOS9UIWebView(userAgent) { - return /(iPhone|iPad|iPod).* OS 9_\d/.test(userAgent) && !/Version\/9\./.test(userAgent); - } - - function applyIOS9Shim(browser) { - var pendingLocationUrl = null; - var originalUrlFn = browser.url; - - browser.url = function() { - if (arguments.length) { - pendingLocationUrl = arguments[0]; - return originalUrlFn.apply(browser, arguments); - } - - return pendingLocationUrl || originalUrlFn.apply(browser, arguments); - }; - - window.addEventListener('popstate', clearPendingLocationUrl, false); - window.addEventListener('hashchange', clearPendingLocationUrl, false); - - function clearPendingLocationUrl() { - pendingLocationUrl = null; - } - - return browser; - } - }]); -}]); - -/** - * @private - * Parts of Ionic requires that $scope data is attached to the element. - * We do not want to disable adding $scope data to the $element when - * $compileProvider.debugInfoEnabled(false) is used. - */ -IonicModule.config(['$provide', function($provide) { - $provide.decorator('$compile', ['$delegate', function($compile) { - $compile.$$addScopeInfo = function $$addScopeInfo($element, scope, isolated, noTemplate) { - var dataName = isolated ? (noTemplate ? '$isolateScopeNoTemplate' : '$isolateScope') : '$scope'; - $element.data(dataName, scope); - }; - return $compile; - }]); -}]); - -/** - * @private - */ -IonicModule.config([ - '$provide', -function($provide) { - function $LocationDecorator($location, $timeout) { - - $location.__hash = $location.hash; - //Fix: when window.location.hash is set, the scrollable area - //found nearest to body's scrollTop is set to scroll to an element - //with that ID. - $location.hash = function(value) { - if (isDefined(value) && value.length > 0) { - $timeout(function() { - var scroll = document.querySelector('.scroll-content'); - if (scroll) { - scroll.scrollTop = 0; - } - }, 0, false); - } - return $location.__hash(value); - }; - - return $location; - } - - $provide.decorator('$location', ['$delegate', '$timeout', $LocationDecorator]); -}]); - -IonicModule - -.controller('$ionicHeaderBar', [ - '$scope', - '$element', - '$attrs', - '$q', - '$ionicConfig', - '$ionicHistory', -function($scope, $element, $attrs, $q, $ionicConfig, $ionicHistory) { - var TITLE = 'title'; - var BACK_TEXT = 'back-text'; - var BACK_BUTTON = 'back-button'; - var DEFAULT_TITLE = 'default-title'; - var PREVIOUS_TITLE = 'previous-title'; - var HIDE = 'hide'; - - var self = this; - var titleText = ''; - var previousTitleText = ''; - var titleLeft = 0; - var titleRight = 0; - var titleCss = ''; - var isBackEnabled = false; - var isBackShown = true; - var isNavBackShown = true; - var isBackElementShown = false; - var titleTextWidth = 0; - - - self.beforeEnter = function(viewData) { - $scope.$broadcast('$ionicView.beforeEnter', viewData); - }; - - - self.title = function(newTitleText) { - if (arguments.length && newTitleText !== titleText) { - getEle(TITLE).innerHTML = newTitleText; - titleText = newTitleText; - titleTextWidth = 0; - } - return titleText; - }; - - - self.enableBack = function(shouldEnable, disableReset) { - // whether or not the back button show be visible, according - // to the navigation and history - if (arguments.length) { - isBackEnabled = shouldEnable; - if (!disableReset) self.updateBackButton(); - } - return isBackEnabled; - }; - - - self.showBack = function(shouldShow, disableReset) { - // different from enableBack() because this will always have the back - // visually hidden if false, even if the history says it should show - if (arguments.length) { - isBackShown = shouldShow; - if (!disableReset) self.updateBackButton(); - } - return isBackShown; - }; - - - self.showNavBack = function(shouldShow) { - // different from showBack() because this is for the entire nav bar's - // setting for all of it's child headers. For internal use. - isNavBackShown = shouldShow; - self.updateBackButton(); - }; - - - self.updateBackButton = function() { - var ele; - if ((isBackShown && isNavBackShown && isBackEnabled) !== isBackElementShown) { - isBackElementShown = isBackShown && isNavBackShown && isBackEnabled; - ele = getEle(BACK_BUTTON); - ele && ele.classList[ isBackElementShown ? 'remove' : 'add' ](HIDE); - } - - if (isBackEnabled) { - ele = ele || getEle(BACK_BUTTON); - if (ele) { - if (self.backButtonIcon !== $ionicConfig.backButton.icon()) { - ele = getEle(BACK_BUTTON + ' .icon'); - if (ele) { - self.backButtonIcon = $ionicConfig.backButton.icon(); - ele.className = 'icon ' + self.backButtonIcon; - } - } - - if (self.backButtonText !== $ionicConfig.backButton.text()) { - ele = getEle(BACK_BUTTON + ' .back-text'); - if (ele) { - ele.textContent = self.backButtonText = $ionicConfig.backButton.text(); - } - } - } - } - }; - - - self.titleTextWidth = function() { - var element = getEle(TITLE); - if ( element ) { - // If the element has a nav-bar-title, use that instead - // to calculate the width of the title - var children = angular.element(element).children(); - for ( var i = 0; i < children.length; i++ ) { - if ( angular.element(children[i]).hasClass('nav-bar-title') ) { - element = children[i]; - break; - } - } - } - var bounds = ionic.DomUtil.getTextBounds(element); - titleTextWidth = Math.min(bounds && bounds.width || 30); - return titleTextWidth; - }; - - - self.titleWidth = function() { - var titleWidth = self.titleTextWidth(); - var offsetWidth = getEle(TITLE).offsetWidth; - if (offsetWidth < titleWidth) { - titleWidth = offsetWidth + (titleLeft - titleRight - 5); - } - return titleWidth; - }; - - - self.titleTextX = function() { - return ($element[0].offsetWidth / 2) - (self.titleWidth() / 2); - }; - - - self.titleLeftRight = function() { - return titleLeft - titleRight; - }; - - - self.backButtonTextLeft = function() { - var offsetLeft = 0; - var ele = getEle(BACK_TEXT); - while (ele) { - offsetLeft += ele.offsetLeft; - ele = ele.parentElement; - } - return offsetLeft; - }; - - - self.resetBackButton = function(viewData) { - if ($ionicConfig.backButton.previousTitleText()) { - var previousTitleEle = getEle(PREVIOUS_TITLE); - if (previousTitleEle) { - previousTitleEle.classList.remove(HIDE); - - var view = (viewData && $ionicHistory.getViewById(viewData.viewId)); - var newPreviousTitleText = $ionicHistory.backTitle(view); - - if (newPreviousTitleText !== previousTitleText) { - previousTitleText = previousTitleEle.innerHTML = newPreviousTitleText; - } - } - var defaultTitleEle = getEle(DEFAULT_TITLE); - if (defaultTitleEle) { - defaultTitleEle.classList.remove(HIDE); - } - } - }; - - - self.align = function(textAlign) { - var titleEle = getEle(TITLE); - - textAlign = textAlign || $attrs.alignTitle || $ionicConfig.navBar.alignTitle(); - - var widths = self.calcWidths(textAlign, false); - - if (isBackShown && previousTitleText && $ionicConfig.backButton.previousTitleText()) { - var previousTitleWidths = self.calcWidths(textAlign, true); - - var availableTitleWidth = $element[0].offsetWidth - previousTitleWidths.titleLeft - previousTitleWidths.titleRight; - - if (self.titleTextWidth() <= availableTitleWidth) { - widths = previousTitleWidths; - } - } - - return self.updatePositions(titleEle, widths.titleLeft, widths.titleRight, widths.buttonsLeft, widths.buttonsRight, widths.css, widths.showPrevTitle); - }; - - - self.calcWidths = function(textAlign, isPreviousTitle) { - var titleEle = getEle(TITLE); - var backBtnEle = getEle(BACK_BUTTON); - var x, y, z, b, c, d, childSize, bounds; - var childNodes = $element[0].childNodes; - var buttonsLeft = 0; - var buttonsRight = 0; - var isCountRightOfTitle; - var updateTitleLeft = 0; - var updateTitleRight = 0; - var updateCss = ''; - var backButtonWidth = 0; - - // Compute how wide the left children are - // Skip all titles (there may still be two titles, one leaving the dom) - // Once we encounter a titleEle, realize we are now counting the right-buttons, not left - for (x = 0; x < childNodes.length; x++) { - c = childNodes[x]; - - childSize = 0; - if (c.nodeType == 1) { - // element node - if (c === titleEle) { - isCountRightOfTitle = true; - continue; - } - - if (c.classList.contains(HIDE)) { - continue; - } - - if (isBackShown && c === backBtnEle) { - - for (y = 0; y < c.childNodes.length; y++) { - b = c.childNodes[y]; - - if (b.nodeType == 1) { - - if (b.classList.contains(BACK_TEXT)) { - for (z = 0; z < b.children.length; z++) { - d = b.children[z]; - - if (isPreviousTitle) { - if (d.classList.contains(DEFAULT_TITLE)) continue; - backButtonWidth += d.offsetWidth; - } else { - if (d.classList.contains(PREVIOUS_TITLE)) continue; - backButtonWidth += d.offsetWidth; - } - } - - } else { - backButtonWidth += b.offsetWidth; - } - - } else if (b.nodeType == 3 && b.nodeValue.trim()) { - bounds = ionic.DomUtil.getTextBounds(b); - backButtonWidth += bounds && bounds.width || 0; - } - - } - childSize = backButtonWidth || c.offsetWidth; - - } else { - // not the title, not the back button, not a hidden element - childSize = c.offsetWidth; - } - - } else if (c.nodeType == 3 && c.nodeValue.trim()) { - // text node - bounds = ionic.DomUtil.getTextBounds(c); - childSize = bounds && bounds.width || 0; - } - - if (isCountRightOfTitle) { - buttonsRight += childSize; - } else { - buttonsLeft += childSize; - } - } - - // Size and align the header titleEle based on the sizes of the left and - // right children, and the desired alignment mode - if (textAlign == 'left') { - updateCss = 'title-left'; - if (buttonsLeft) { - updateTitleLeft = buttonsLeft + 15; - } - if (buttonsRight) { - updateTitleRight = buttonsRight + 15; - } - - } else if (textAlign == 'right') { - updateCss = 'title-right'; - if (buttonsLeft) { - updateTitleLeft = buttonsLeft + 15; - } - if (buttonsRight) { - updateTitleRight = buttonsRight + 15; - } - - } else { - // center the default - var margin = Math.max(buttonsLeft, buttonsRight) + 10; - if (margin > 10) { - updateTitleLeft = updateTitleRight = margin; - } - } - - return { - backButtonWidth: backButtonWidth, - buttonsLeft: buttonsLeft, - buttonsRight: buttonsRight, - titleLeft: updateTitleLeft, - titleRight: updateTitleRight, - showPrevTitle: isPreviousTitle, - css: updateCss - }; - }; - - - self.updatePositions = function(titleEle, updateTitleLeft, updateTitleRight, buttonsLeft, buttonsRight, updateCss, showPreviousTitle) { - var deferred = $q.defer(); - - // only make DOM updates when there are actual changes - if (titleEle) { - if (updateTitleLeft !== titleLeft) { - titleEle.style.left = updateTitleLeft ? updateTitleLeft + 'px' : ''; - titleLeft = updateTitleLeft; - } - if (updateTitleRight !== titleRight) { - titleEle.style.right = updateTitleRight ? updateTitleRight + 'px' : ''; - titleRight = updateTitleRight; - } - - if (updateCss !== titleCss) { - updateCss && titleEle.classList.add(updateCss); - titleCss && titleEle.classList.remove(titleCss); - titleCss = updateCss; - } - } - - if ($ionicConfig.backButton.previousTitleText()) { - var prevTitle = getEle(PREVIOUS_TITLE); - var defaultTitle = getEle(DEFAULT_TITLE); - - prevTitle && prevTitle.classList[ showPreviousTitle ? 'remove' : 'add'](HIDE); - defaultTitle && defaultTitle.classList[ showPreviousTitle ? 'add' : 'remove'](HIDE); - } - - ionic.requestAnimationFrame(function() { - if (titleEle && titleEle.offsetWidth + 10 < titleEle.scrollWidth) { - var minRight = buttonsRight + 5; - var testRight = $element[0].offsetWidth - titleLeft - self.titleTextWidth() - 20; - updateTitleRight = testRight < minRight ? minRight : testRight; - if (updateTitleRight !== titleRight) { - titleEle.style.right = updateTitleRight + 'px'; - titleRight = updateTitleRight; - } - } - deferred.resolve(); - }); - - return deferred.promise; - }; - - - self.setCss = function(elementClassname, css) { - ionic.DomUtil.cachedStyles(getEle(elementClassname), css); - }; - - - var eleCache = {}; - function getEle(className) { - if (!eleCache[className]) { - eleCache[className] = $element[0].querySelector('.' + className); - } - return eleCache[className]; - } - - - $scope.$on('$destroy', function() { - for (var n in eleCache) eleCache[n] = null; - }); - -}]); - -IonicModule -.controller('$ionInfiniteScroll', [ - '$scope', - '$attrs', - '$element', - '$timeout', -function($scope, $attrs, $element, $timeout) { - var self = this; - self.isLoading = false; - - $scope.icon = function() { - return isDefined($attrs.icon) ? $attrs.icon : 'ion-load-d'; - }; - - $scope.spinner = function() { - return isDefined($attrs.spinner) ? $attrs.spinner : ''; - }; - - $scope.$on('scroll.infiniteScrollComplete', function() { - finishInfiniteScroll(); - }); - - $scope.$on('$destroy', function() { - if (self.scrollCtrl && self.scrollCtrl.$element) self.scrollCtrl.$element.off('scroll', self.checkBounds); - if (self.scrollEl && self.scrollEl.removeEventListener) { - self.scrollEl.removeEventListener('scroll', self.checkBounds); - } - }); - - // debounce checking infinite scroll events - self.checkBounds = ionic.Utils.throttle(checkInfiniteBounds, 300); - - function onInfinite() { - ionic.requestAnimationFrame(function() { - $element[0].classList.add('active'); - }); - self.isLoading = true; - $scope.$parent && $scope.$parent.$apply($attrs.onInfinite || ''); - } - - function finishInfiniteScroll() { - ionic.requestAnimationFrame(function() { - $element[0].classList.remove('active'); - }); - $timeout(function() { - if (self.jsScrolling) self.scrollView.resize(); - // only check bounds again immediately if the page isn't cached (scroll el has height) - if ((self.jsScrolling && self.scrollView.__container && self.scrollView.__container.offsetHeight > 0) || - !self.jsScrolling) { - self.checkBounds(); - } - }, 30, false); - self.isLoading = false; - } - - // check if we've scrolled far enough to trigger an infinite scroll - function checkInfiniteBounds() { - if (self.isLoading) return; - var maxScroll = {}; - - if (self.jsScrolling) { - maxScroll = self.getJSMaxScroll(); - var scrollValues = self.scrollView.getValues(); - if ((maxScroll.left !== -1 && scrollValues.left >= maxScroll.left) || - (maxScroll.top !== -1 && scrollValues.top >= maxScroll.top)) { - onInfinite(); - } - } else { - maxScroll = self.getNativeMaxScroll(); - if (( - maxScroll.left !== -1 && - self.scrollEl.scrollLeft >= maxScroll.left - self.scrollEl.clientWidth - ) || ( - maxScroll.top !== -1 && - self.scrollEl.scrollTop >= maxScroll.top - self.scrollEl.clientHeight - )) { - onInfinite(); - } - } - } - - // determine the threshold at which we should fire an infinite scroll - // note: this gets processed every scroll event, can it be cached? - self.getJSMaxScroll = function() { - var maxValues = self.scrollView.getScrollMax(); - return { - left: self.scrollView.options.scrollingX ? - calculateMaxValue(maxValues.left) : - -1, - top: self.scrollView.options.scrollingY ? - calculateMaxValue(maxValues.top) : - -1 - }; - }; - - self.getNativeMaxScroll = function() { - var maxValues = { - left: self.scrollEl.scrollWidth, - top: self.scrollEl.scrollHeight - }; - var computedStyle = window.getComputedStyle(self.scrollEl) || {}; - return { - left: maxValues.left && - (computedStyle.overflowX === 'scroll' || - computedStyle.overflowX === 'auto' || - self.scrollEl.style['overflow-x'] === 'scroll') ? - calculateMaxValue(maxValues.left) : -1, - top: maxValues.top && - (computedStyle.overflowY === 'scroll' || - computedStyle.overflowY === 'auto' || - self.scrollEl.style['overflow-y'] === 'scroll' ) ? - calculateMaxValue(maxValues.top) : -1 - }; - }; - - // determine pixel refresh distance based on % or value - function calculateMaxValue(maximum) { - var distance = ($attrs.distance || '2.5%').trim(); - var isPercent = distance.indexOf('%') !== -1; - return isPercent ? - maximum * (1 - parseFloat(distance) / 100) : - maximum - parseFloat(distance); - } - - //for testing - self.__finishInfiniteScroll = finishInfiniteScroll; - -}]); - -/** - * @ngdoc service - * @name $ionicListDelegate - * @module ionic - * - * @description - * Delegate for controlling the {@link ionic.directive:ionList} directive. - * - * Methods called directly on the $ionicListDelegate service will control all lists. - * Use the {@link ionic.service:$ionicListDelegate#$getByHandle $getByHandle} - * method to control specific ionList instances. - * - * @usage - * ```html - * {% raw %} - * - * - * - * - * Hello, {{i}}! - * - * - * - * - * {% endraw %} - * ``` - - * ```js - * function MyCtrl($scope, $ionicListDelegate) { - * $scope.showDeleteButtons = function() { - * $ionicListDelegate.showDelete(true); - * }; - * } - * ``` - */ -IonicModule.service('$ionicListDelegate', ionic.DelegateService([ - /** - * @ngdoc method - * @name $ionicListDelegate#showReorder - * @param {boolean=} showReorder Set whether or not this list is showing its reorder buttons. - * @returns {boolean} Whether the reorder buttons are shown. - */ - 'showReorder', - /** - * @ngdoc method - * @name $ionicListDelegate#showDelete - * @param {boolean=} showDelete Set whether or not this list is showing its delete buttons. - * @returns {boolean} Whether the delete buttons are shown. - */ - 'showDelete', - /** - * @ngdoc method - * @name $ionicListDelegate#canSwipeItems - * @param {boolean=} canSwipeItems Set whether or not this list is able to swipe to show - * option buttons. - * @returns {boolean} Whether the list is able to swipe to show option buttons. - */ - 'canSwipeItems', - /** - * @ngdoc method - * @name $ionicListDelegate#closeOptionButtons - * @description Closes any option buttons on the list that are swiped open. - */ - 'closeOptionButtons' - /** - * @ngdoc method - * @name $ionicListDelegate#$getByHandle - * @param {string} handle - * @returns `delegateInstance` A delegate instance that controls only the - * {@link ionic.directive:ionList} directives with `delegate-handle` matching - * the given handle. - * - * Example: `$ionicListDelegate.$getByHandle('my-handle').showReorder(true);` - */ -])) - -.controller('$ionicList', [ - '$scope', - '$attrs', - '$ionicListDelegate', - '$ionicHistory', -function($scope, $attrs, $ionicListDelegate, $ionicHistory) { - var self = this; - var isSwipeable = true; - var isReorderShown = false; - var isDeleteShown = false; - - var deregisterInstance = $ionicListDelegate._registerInstance( - self, $attrs.delegateHandle, function() { - return $ionicHistory.isActiveScope($scope); - } - ); - $scope.$on('$destroy', deregisterInstance); - - self.showReorder = function(show) { - if (arguments.length) { - isReorderShown = !!show; - } - return isReorderShown; - }; - - self.showDelete = function(show) { - if (arguments.length) { - isDeleteShown = !!show; - } - return isDeleteShown; - }; - - self.canSwipeItems = function(can) { - if (arguments.length) { - isSwipeable = !!can; - } - return isSwipeable; - }; - - self.closeOptionButtons = function() { - self.listView && self.listView.clearDragEffects(); - }; -}]); - -IonicModule - -.controller('$ionicNavBar', [ - '$scope', - '$element', - '$attrs', - '$compile', - '$timeout', - '$ionicNavBarDelegate', - '$ionicConfig', - '$ionicHistory', -function($scope, $element, $attrs, $compile, $timeout, $ionicNavBarDelegate, $ionicConfig, $ionicHistory) { - - var CSS_HIDE = 'hide'; - var DATA_NAV_BAR_CTRL = '$ionNavBarController'; - var PRIMARY_BUTTONS = 'primaryButtons'; - var SECONDARY_BUTTONS = 'secondaryButtons'; - var BACK_BUTTON = 'backButton'; - var ITEM_TYPES = 'primaryButtons secondaryButtons leftButtons rightButtons title'.split(' '); - - var self = this; - var headerBars = []; - var navElementHtml = {}; - var isVisible = true; - var queuedTransitionStart, queuedTransitionEnd, latestTransitionId; - - $element.parent().data(DATA_NAV_BAR_CTRL, self); - - var delegateHandle = $attrs.delegateHandle || 'navBar' + ionic.Utils.nextUid(); - - var deregisterInstance = $ionicNavBarDelegate._registerInstance(self, delegateHandle); - - - self.init = function() { - $element.addClass('nav-bar-container'); - ionic.DomUtil.cachedAttr($element, 'nav-bar-transition', $ionicConfig.views.transition()); - - // create two nav bar blocks which will trade out which one is shown - self.createHeaderBar(false); - self.createHeaderBar(true); - - $scope.$emit('ionNavBar.init', delegateHandle); - }; - - - self.createHeaderBar = function(isActive) { - var containerEle = jqLite('