diff --git a/bin/gen_release_app_zip.sh b/bin/gen_release_app_zip.sh new file mode 100644 index 000000000..abcd193c9 --- /dev/null +++ b/bin/gen_release_app_zip.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +PROJECT=$1 +VERSION=$2 + +pushd platforms/ios/build/emulator +zip -o ../../../../$PROJECT-build-$VERSION.app.zip -r NREL\ OpenPATH.app +popd diff --git a/bin/sign_and_align_keys.sh b/bin/sign_and_align_keys.sh index 3a0bb0d76..261058bd5 100644 --- a/bin/sign_and_align_keys.sh +++ b/bin/sign_and_align_keys.sh @@ -10,7 +10,7 @@ fi # Sign and release the L+ version # Make sure the highest supported version has the biggest version code -npx cordova build android --release -- --minSdkVersion=22 +npm run build-prod-android # 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/config.cordovabuild.xml b/config.cordovabuild.xml index 6a0c1480e..d3d562802 100644 --- a/config.cordovabuild.xml +++ b/config.cordovabuild.xml @@ -13,7 +13,6 @@ - @@ -48,8 +47,6 @@ - - diff --git a/package.cordovabuild.json b/package.cordovabuild.json index 72c8304aa..8124f45c5 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -11,7 +11,10 @@ "build": "npx webpack --config webpack.prod.js && npx cordova build", "build-dev": "npx webpack --config webpack.dev.js && npx cordova build", "build-dev-android": "npx webpack --config webpack.dev.js && npx cordova build android", - "build-dev-ios": "npx webpack --config webpack.dev.js && npx cordova build ios" + "build-dev-ios": "npx webpack --config webpack.dev.js && npx cordova build ios", + "build-prod-android": "npx webpack --config webpack.prod.js && npx cordova build android", + "build-prod-ios": "npx webpack --config webpack.prod.js && npx cordova build ios", + "build-prod-android-release": "npx webpack --config webpack.prod.js && npx cordova build android --release" }, "devDependencies": { "@babel/core": "^7.21.3", @@ -115,14 +118,14 @@ "chart.js": "^4.3.0", "chartjs-adapter-luxon": "^1.3.1", "chartjs-plugin-annotation": "^3.0.1", - "cordova-android": "11.0.0", + "cordova-android": "12.0.0", "cordova-ios": "6.2.0", "cordova-plugin-advanced-http": "3.3.1", "cordova-plugin-androidx-adapter": "1.1.3", "cordova-plugin-app-version": "0.1.14", "cordova-plugin-customurlscheme": "5.0.2", "cordova-plugin-device": "2.1.0", - "cordova-plugin-em-datacollection": "git+https://github.com/e-mission/e-mission-data-collection.git#v1.7.8", + "cordova-plugin-em-datacollection": "git+https://github.com/e-mission/e-mission-data-collection.git#v1.7.9", "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", @@ -130,7 +133,7 @@ "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-email-composer": "git+https://github.com/katzer/cordova-plugin-email-composer.git#0.10.1", - "cordova-plugin-file": "7.0.0", + "cordova-plugin-file": "8.0.0", "cordova-plugin-inappbrowser": "5.0.0", "cordova-plugin-ionic-keyboard": "2.2.0", "cordova-plugin-ionic-webview": "5.0.0", @@ -165,7 +168,7 @@ "react-native-safe-area-context": "^4.6.3", "react-native-screens": "^3.22.0", "react-native-vector-icons": "^9.2.0", - "react-native-web": "^0.18.10", + "react-native-web": "^0.19.7", "react-native-web-webview": "^1.0.2", "react-qr-code": "^2.0.11", "shelljs": "^0.8.5" diff --git a/package.serve.json b/package.serve.json index 88dc636a8..da3b60b89 100644 --- a/package.serve.json +++ b/package.serve.json @@ -87,7 +87,7 @@ "react-native-safe-area-context": "^4.6.3", "react-native-screens": "^3.22.0", "react-native-vector-icons": "^9.2.0", - "react-native-web": "^0.18.10", + "react-native-web": "^0.19.7", "react-native-web-webview": "^1.0.2", "react-qr-code": "^2.0.11", "shelljs": "^0.8.5" diff --git a/www/css/style.css b/www/css/style.css index b5186e001..9a6f708bd 100644 --- a/www/css/style.css +++ b/www/css/style.css @@ -2,6 +2,16 @@ /* 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 { @@ -1471,16 +1481,3 @@ svg { width: 3ch; left: calc(8px + 2.5ch); } - -.enketo-plugin .question.inline-datetime > input[name*="_date"], -.enketo-plugin .question.inline-datetime > input[name*="_time"] { - display: flex; - width: auto; - min-width: 65%; - max-width: 85%; -} - -.enketo-plugin .question.inline-datetime { - display: inline-grid; - width: 50%; -} diff --git a/www/i18n/en.json b/www/i18n/en.json index 70891020d..0949d5c5c 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -1,69 +1,27 @@ { "loading" : "Loading...", - "map-refresh": "Refresh", - "map-fixmap": "Fix Map", "pull-to-refresh": "Pull to refresh", "weekdays-all": "All", "weekdays-select": "Select day of the week", - "post-trip-prompt":{ - "notification-option-mute": "Mute", - "notification-option-snooze": "Snooze", - "notification-option-choose": "Choose", - "notification-title": "How and why did you come here?", - "choose-mode": "Choose Mode", - "skip": "Skip", - "snoozed-reminder": "Snoozed reminder", - "snoozed-reapper-message": "Will reappear in 30 mins", - "platform-specific-message-ios": "Swipe left or tap to add information about this trip.", - "platform-specific-message-android": "See options or tap to add information about this trip.", - "platform-specific-message-other": "Tap to add information about this trip.", - "notifications-muted": "Notifications for TRIP_END incident report muted", - "notifications-reenabled": "Can be re-enabled from the Profile -> Developer Zone screen. Select to re-enable now, clear to ignore", - "muted": "Muted", - "unmute": "Unmute", - "keep-muted": "Keep muted" - }, - - "post-trip-map-display-tour-incident": "Zoom in as much as possible to the location where the incident occurred and click on the blue line of the trip to mark a ☻ or ☹ incident", - - "tour-next": "Next", - "tour-previous": "Previous", - "tour-finish": "Finish", - "trip-confirm": { - "recenttrip": "Recent trip from: {{startTime}} → to: {{endTime}}", - "continue": "Continue", - "done": "Done", "services-please-fill-in": "Please fill in the {{text}} not listed.", "services-cancel": "Cancel", "services-save": "Save" }, - "place-common-place": "Common place", - "place-successor-trips": "Successor trips", - "place-trips-to": "{{trips}} trips to", - "place-usually-starts": "Usually starts at: {{hour}}:00", - "place-usually-takes": "Usually takes: {{duration}}", - - "trip-start-hours": "Start hours", - "trip-start-duration": "Duration", - "control":{ "profile": "Profile", "edit-demographics": "Edit Demographics", - "username": "Username {{usernamedata}}", "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", - "dark-theme": "Dark theme", "force-sync": "Force sync", "share": "Share", - "check-ui-updates": "Check for UI updates", "download-json-dump": "Download json dump", "email-log": "Email log", "upload-log": "Upload log", @@ -77,13 +35,10 @@ "invalidate-cached-docs": "Invalidate cached docs", "nuke-all": "Nuke all buffers and cache", "test-notification": "Test local notification", - "set-ui-channel": "Set UI channel", "check-log": "Check log", "check-sensed-data": "Check sensed data", - "check-map": "Check map", "collection": "Collection", "sync": "Sync", - "transition-notify": "Transition Notify", "button-accept": "I accept", "view-qrc": "My OPcode", "app-version": "App Version", @@ -114,9 +69,7 @@ "share-subject": "Emission - UC Berkeley Research Project", "share-url": "https://bic2cal.eecs.berkeley.edu/#download", "qrcode": "My OPcode", - "qrcode-share-title": "You can save your OPcode to login easily in the future!", - "qrcode-share-message": "Save this OPcode or send to a new device to scan and login! \n[OpenPath] My OPcode: ", - "qrcode-share-subject": "My OPcode - OpenPATH" + "qrcode-share-title": "You can save your OPcode to login easily in the future!" }, "metrics":{ @@ -150,24 +103,14 @@ "less-than": " less than ", "less": " less ", "week-before": "vs. week before", - "calorie-data-change-increase": " increase over a week", - "calorie-data-change-decrease": " decrease over a week", "pick-a-date": "Pick a date", "trips": "trips", "hours": "hours", "custom": "Custom" }, - + "diary": { - "current-trip": "Current Trip", - "current-yesterday": "Yesterday", - "current-weekago": "Week ago", - "history": "History", - "began": "Began {{startTime}}", - "report-incident": "Report Incident", - "draft": "DRAFT", "distance-in-time": "{{distance}} {{distsuffix}} in {{time}}", - "date-distance-in-time": "{{date}}: {{distance}} {{distsuffix}} in {{time}}", "distance": "Distance", "time": "Time", "mode": "Mode", @@ -180,10 +123,6 @@ "to-label": "To Label", "show-all": "All Trips", "no-trips-found": "No trips found", - "for-current-filter": "for current filter. Show All to remove filters", - "scroll-to-load-more": "Scroll to load more", - "filter-display-status": "Displaying {{displayTripsLength}} / {{allTripsLength}} trips", - "filter-display-range": "{{currentEnd | date}} to {{currentStart | date:'medium' }}", "choose-mode": "Mode 📝 ", "choose-replaced-mode": "Replaces 📝", "choose-purpose": "Purpose 📝", @@ -191,29 +130,12 @@ "select-mode-scroll": "Mode (👇 for more)", "select-replaced-mode-scroll": "Replaces (👇 for more)", "select-purpose-scroll": "Purpose (👇 for more)", - "list-pick-a-date": "Pick a date", "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!", - "no-trips-today": "No trips recorded on this day" - }, - - "new_label_tour": { - "0": "This is the new Label user interface. Label trips here instead of on the Diary page. Trips take longer to appear here, but when they do they will be more accurate and you will be able to use our new features to label them faster.", - "1": "This is the To Label tab. On this tab, only the trips you need to label will appear; when you label them, they will automatically disappear after a few seconds.", - "2": "Some of your trips do not appear in To Label because an algorithm labeled them for you. If you're curious, you can see these in the other tabs, but you only need to label trips on the To Label tab.", - "3": "Trips now appear with the newest trip at the bottom.", - "4": "To load older trips, scroll up and press the load button.", - "5": "Labels are now red if we couldn't predict them, yellow if we could predict them, and green if you entered or confirmed them yourself.", - "6": "If you see yellow labels on the To Label tab, this means you need to confirm or correct them.", - "7": "If all of a trip's yellow labels are correct, you can click the checkmark button, which will turn them green (the checkmark button doesn't do anything to red or green labels.", - "8": "If certain yellow labels are incorrect, you can correct them just like you'd enter a label normally.", - "9": "When there are no more trips in To Label, you're done labeling for the day.", - "10": "The more you label your trips, the better the algorithm gets at predicting your trips for you, so keeping up-to-date with your labeling will save you work in the long run!", - "11": "Click the ‘?’ button whenever you'd like to view this tour again." + "no-travel-hint": "To see more, change the filters above or go record some travel!" }, "user-gender": "Gender", @@ -227,21 +149,6 @@ "dashboard": "Dashboard", "summary": "My Summary", "chart": "Chart", - "mybear": "My Bear", - "leaderboard": "Leaderboard", - "carbon-usage": "Carbon Usage Past 7 Days", - "trip-labelling-stats": "Trip Labeling Statistics", - "gold-tier": "Gold tier", - "silver-tier": "Silver tier", - "bronze-tier": "Bronze tier", - "overallScoreMsg": "Overall Score: {{overallScore | number }}", - "confirmationMsg": "{{confirmedPct | number }}% labeled", - "validLabelsMsg": "{{validReplacePct | number }}% eBike 'replaced mode' labels valid", - "suggestion": "Suggestion", - "suggestion-savings": "You can save {{suggestionData.savings}} in a month", - "recent-trips": "Recent Trips", - "no-available-recent-trips": "No available recent trips", - "weekly-stats": "Weekly Stats", "change-data": "Change dates:", "distance": "My Distance", "trips": "My Trips", @@ -269,56 +176,24 @@ "equals-bananas_other": "Equals at least {{count}} bananas" }, - "main-diary" : "Diary", - "main-inf-scroll" : { "tab": "Label" }, - "main-heatmap":{ - "title": "Heatmap", - "counts" : "Counts", - "stress" : "Stress", - "from" : "From:", - "to" : "To:", - "get" : "Get!", - "all": "ALL", - "none": "NONE", - "bicycling": "BICYCLING", - "walking": "WALKING", - "in-vehicle": "IN_VEHICLE", - "select-travel-mode" : "Select travel mode", - "cancel": "Cancel", - "tour-datepicker": "This heatmap shows the aggregate data for all E-mission users. Select the dates you want to see, and filter by hours of the day (24h format) and days of the week. For example, if you enter 16 and 19 in the last field, and select Monday and Friday, you'll see the Heatmap filtered to show the traffic on weekdays between 4pm and 7pm.", - "tour-mode": "Click here to filter your results by mode of transportation. The default is to show all modes.", - "tour-get": "Click here to generate the heatmap." - }, - "details":{ "speed": "Speed", - "time": "Time", - "tour-detail-content": "To report an incident, zoom in as much as possible to the location where the incident occurred and click on the trip to mark a ☻ or ☹ incident", - "tour-sectionList-content": "Trip sections, along with times and modes", - "tour-sectionPct-content": "% of time spent in each mode for this trip" + "time": "Time" }, - "list-explainDraft-alert": "This trip has not yet been analysed. If it stays in this state, please ask your sysadmin to check what is wrong.", "list-datepicker-today": "Today", "list-datepicker-close": "Close", "list-datepicker-set": "Set", - "list-tour-datepicker-button" : "Use this to select the day you want to see.", - "list-tour-diary-entry" : "Click on the map to see more details about each trip.", - "list-tour-map-fix-button" : "Use this to fix the map tiles if they have not loaded properly.", "service":{ "reading-server": "Reading from server...", - "reading-cache": "Reading from cache...", "reading-unprocessed-data": "Reading unprocessed data..." }, - - "post-trip-manual-incident-time" : "Choose incident 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", @@ -338,7 +213,6 @@ "upload-database": "Uploading database {{db}}", "upload-from-dir": "from directory {{parentDir}}", "upload-to-server": "to servers {{serverURL}}", - "going-to-email": "Going to email database from {{parentDir}}", "please-fill-in-what-is-wrong": "please fill in what is wrong", "upload-success": "Upload successful", "upload-progress": "Sending {{filesizemb | number}} MB to {{serverURL}}", @@ -386,10 +260,8 @@ "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", - "not-paused-name": "Not Paused", "description": { "android-enable": "On the app settings page, ensure that all notifications and channels are enabled.", - "android-unpause": "On the app settings page, ensure that all notifications are enabled. If this doesn't fix the problem, ask for help from your admin", "ios-enable": "Please allow, on the popup or the app settings page if necessary" } }, @@ -412,10 +284,6 @@ } }, "permissions": { - "locationPermExplanation-android-lt-6": "you accepted the permission during installation. You don't need to do anything now.", - "locationPermExplanation-android-6-9": "please select 'allow'", - "locationPermExplanation-android-10": "please select 'Allow all the time'", - "locationPermExplanation-android-gte-11": "please select 'Allow all the time' for location permissions on the app page", "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" } @@ -428,14 +296,6 @@ "button-accept": "I accept", "button-decline": "I refuse" }, - "updatecheck":{ - "downloading-update": "Downloading UI-only update", - "extracting-update": "Extracting UI-only update", - "done": "Update done, reloading...", - "download-new-ui": "Download new UI-only update to build {{build}}?", - "download-not-now": "Not now", - "download-apply": "Apply" - }, "login":{ "make-sure-save-your-opcode":"Make sure to save your OPcode!", "cannot-retrieve":"NREL cannot retrieve it for you later!", @@ -445,10 +305,6 @@ "button-accept": "OK", "button-decline": "Cancel" }, - "sensor_explanation":{ - "button-accept": "OK", - "button-decline": "Stop" - }, "survey": { "loading-prior-survey": "Loading prior survey responses...", "prev-survey-found": "Found previous survey response", diff --git a/www/index.js b/www/index.js index 22ec4704b..66a0d45df 100644 --- a/www/index.js +++ b/www/index.js @@ -26,10 +26,8 @@ import './js/services.js'; import './js/i18n-utils.js'; import './js/intro.js'; import './js/main.js'; -import './js/survey/survey.js'; import './js/survey/input-matcher.js'; import './js/survey/multilabel/infinite_scroll_filters.js'; -import './js/survey/multilabel/trip-confirm-services.js'; import './js/survey/multilabel/multi-label-ui.js'; import './js/diary.js'; import './js/recent.js'; @@ -50,6 +48,6 @@ import './js/control/collect-settings.js'; import './js/control/sync-settings.js'; import './js/metrics-factory.js'; import './js/metrics-mappings.js'; -import './js/plugin/logger.js'; +import './js/plugin/logger.ts'; import './js/plugin/storage.js'; import './js/appstatus/permissioncheck.js'; diff --git a/www/js/ReactHello.jsx b/www/js/ReactHello.jsx deleted file mode 100644 index 84b81f71e..000000000 --- a/www/js/ReactHello.jsx +++ /dev/null @@ -1,93 +0,0 @@ -import React from "react"; -import { Image, Pressable, StyleSheet, Text, View } from "react-native"; -import { angularize } from "./angular-react-helper"; - -const logoUri = `data:image/svg+xml;utf8,`; - -function Link(props) { - return ; -} - -function ReactHello() { - return ( - - - - React Native for Web - - - This is an example of an app built with{" "} - - Create React App - {" "} - and{" "} - - React Native for Web - - - - To get started, edit{" "} - - src/App.js - - . - - {}} style={buttonStyles.button}> - Example button - - - ); -} - -const styles = StyleSheet.create({ - app: { - marginHorizontal: "auto", - maxWidth: 500 - }, - logo: { - height: 80 - }, - header: { - padding: 20 - }, - title: { - fontWeight: "bold", - fontSize: "1.5rem", - marginVertical: "1em", - textAlign: "center" - }, - text: { - lineHeight: "1.5em", - fontSize: "1.125rem", - marginVertical: "1em", - textAlign: "center" - }, - link: { - color: "#1B95E0" - }, - code: { - fontFamily: "monospace, monospace" - } -}); - -const buttonStyles = StyleSheet.create({ - button: { - backgroundColor: "#2196F3", - borderRadius: 2 - }, - text: { - color: "#fff", - fontWeight: "500", - padding: 8, - textAlign: "center", - textTransform: "uppercase" - } -}); - -angularize(ReactHello, 'ReactHello', 'emission.main.reacthello'); -export default ReactHello; diff --git a/www/js/appTheme.ts b/www/js/appTheme.ts index 90862a2b3..dfae3464e 100644 --- a/www/js/appTheme.ts +++ b/www/js/appTheme.ts @@ -57,13 +57,13 @@ const flavorOverrides = { }, } }, - draft: { // for draft TripCards; a greenish color scheme + draft: { // for draft TripCards; a greyish color scheme colors: { - primary: '#637d6a', // lch(50 15 150) - primaryContainer: '#b8cbbd', // lch(80 10 150) + primary: '#616971', // lch(44 6 250) + primaryContainer: '#b6bcc2', // lch(76 4 250) elevation: { - level1: '#e1e3e1', // lch(90 1 150) - level2: '#d7dbd8', // lch(87 2 150) + level1: '#dbddde', // lch(88 1 250) + level2: '#d2d5d8', // lch(85 2 250) }, } }, diff --git a/www/js/commHelper.ts b/www/js/commHelper.ts new file mode 100644 index 000000000..074093999 --- /dev/null +++ b/www/js/commHelper.ts @@ -0,0 +1,19 @@ +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/Icon.tsx b/www/js/components/Icon.tsx new file mode 100644 index 000000000..0b4c7253e --- /dev/null +++ b/www/js/components/Icon.tsx @@ -0,0 +1,26 @@ +/* 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.jsx b/www/js/components/LeafletView.jsx index 380dad086..b3ee4184b 100644 --- a/www/js/components/LeafletView.jsx +++ b/www/js/components/LeafletView.jsx @@ -50,7 +50,7 @@ const LeafletView = ({ geojson, opts, ...otherProps }) => { const mapElId = `map-${geojson.data.id.replace(/[^a-zA-Z0-9]/g, '')}`; return ( - + -
); diff --git a/www/js/components/NavBarButton.tsx b/www/js/components/NavBarButton.tsx index d58f70f93..7e9cb1217 100644 --- a/www/js/components/NavBarButton.tsx +++ b/www/js/components/NavBarButton.tsx @@ -1,9 +1,10 @@ import React from "react"; import { View, StyleSheet } from "react-native"; import color from "color"; -import { Button, IconButton, useTheme } from "react-native-paper"; +import { Button, useTheme } from "react-native-paper"; +import { Icon } from "./Icon"; -const NavBarButton = ({ children, icon, onPressAction }) => { +const NavBarButton = ({ children, icon, onPressAction, ...otherProps }) => { const { colors } = useTheme(); const buttonColor = color(colors.onBackground).alpha(.07).rgb().string(); @@ -13,14 +14,14 @@ const NavBarButton = ({ children, icon, onPressAction }) => { diff --git a/www/js/config/dynamic_config.js b/www/js/config/dynamic_config.js index 337469527..9daabe2c4 100644 --- a/www/js/config/dynamic_config.js +++ b/www/js/config/dynamic_config.js @@ -1,6 +1,9 @@ 'use strict'; import angular from 'angular'; +import { displayError, logDebug } from '../plugin/logger'; +import i18next from 'i18next'; +import { fetchUrlCached } from '../commHelper'; angular.module('emission.config.dynamic', ['emission.plugin.logger', 'emission.plugin.kvstore']) @@ -43,23 +46,60 @@ angular.module('emission.config.dynamic', ['emission.plugin.logger', } } - var readConfigFromServer = function(label) { - Logger.log("Received request to join "+label); - // The URL prefix from which config files will be downloaded and read. - // Change this if you supply your own config files. - const downloadURL = "https://raw.githubusercontent.com/e-mission/nrel-openpath-deploy-configs/main/configs/"+label+".nrel-op.json" - Logger.log("Downloading data from "+downloadURL); - return $http.get(downloadURL).then((result) => { - Logger.log("Successfully found the "+downloadURL+", result is " + JSON.stringify(result.data).substring(0,10)); - const parsedConfig = result.data; - const connectionURL = parsedConfig.server? parsedConfig.server.connectUrl : "dev defaults"; - _fillStudyName(parsedConfig); - _backwardsCompatSurveyFill(parsedConfig); - Logger.log("Successfully downloaded config with version "+parsedConfig.version - +" for "+parsedConfig.intro.translated_text.en.deployment_name - +" and data collection URL "+connectionURL); - return parsedConfig; + /* 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) => { + if (config.survey_info?.surveys) { + Object.values(config.survey_info.surveys).forEach((survey) => { + if (!survey?.formPath) + throw new Error('while fetching resources in config, survey_info.surveys has a survey without a formPath'); + fetchUrlCached(survey.formPath); }); + } + if (config.label_options) { + fetchUrlCached(config.label_options); + } + } + + const readConfigFromServer = async (label) => { + const config = await fetchConfig(label); + Logger.log("Successfully found config, result is " + JSON.stringify(config).substring(0, 10)); + + // fetch + cache any resources referenced in the config, but don't 'await' them so we don't block + // the config loading process + cacheResourcesFromConfig(config); + + const connectionURL = config.server ? config.server.connectUrl : "dev defaults"; + _fillStudyName(config); + _backwardsCompatSurveyFill(config); + Logger.log("Successfully downloaded config with version " + config.version + + " for " + config.intro.translated_text.en.deployment_name + + " and data collection URL " + connectionURL); + return config; + } + + const fetchConfig = async (label, alreadyTriedLocal=false) => { + Logger.log("Received request to join "+label); + const downloadURL = `https://raw.githubusercontent.com/e-mission/nrel-openpath-deploy-configs/main/configs/${label}.nrel-op.json`; + if (!__DEV__ || alreadyTriedLocal) { + Logger.log("Fetching config from github"); + const r = await fetch(downloadURL); + if (!r.ok) throw new Error('Unable to fetch config from github'); + return r.json(); + } + else { + Logger.log("Running in dev environment, checking for locally hosted config"); + try { + const r = await fetch('http://localhost:9090/configs/'+label+'.nrel-op.json'); + if (!r.ok) throw new Error('Local config not found'); + return r.json(); + } catch (err) { + Logger.log("Local config not found"); + return fetchConfig(label, true); + } + } } dc.loadSavedConfig = function() { @@ -139,6 +179,8 @@ angular.module('emission.config.dynamic', ['emission.plugin.logger', return true; }).catch((storeError) => Logger.displayError(i18next.t('config.unable-to-store-config'), storeError)); + }).catch((fetchErr) => { + displayError(fetchErr, i18next.t('config.unable-download-config')); }); } @@ -238,7 +280,7 @@ angular.module('emission.config.dynamic', ['emission.plugin.logger', } else { if (tokenParts[2] != "default") { // subpart not in config list - throw new Error(i18next.t('config.invalid-subgroup', {token: token})); + throw new Error(i18next.t('config.invalid-subgroup-no-default', {token: token})); } else { console.log("no subgroups in config, 'default' subgroup found in token "); return tokenParts[2]; @@ -296,7 +338,7 @@ angular.module('emission.config.dynamic', ['emission.plugin.logger', $rootScope.$apply(() => dc.saveAndNotifyConfigReady(existingConfig)); } }).catch((err) => { - Logger.displayError(i18next.t('config.loading-config-app-start', err)) + Logger.displayError(i18next.t('config.error-loading-config-app-start', err)) }); }; $ionicPlatform.ready().then(function() { diff --git a/www/js/config/useImperialConfig.ts b/www/js/config/useImperialConfig.ts index cb433577e..3ddb287a2 100644 --- a/www/js/config/useImperialConfig.ts +++ b/www/js/config/useImperialConfig.ts @@ -1,14 +1,27 @@ import React, { useEffect, useState } from "react"; import useAppConfig from "../useAppConfig"; -const getFormattedDistanceInKm = (dist_in_meters) => { - if (dist_in_meters >= 1000) - return Number.parseFloat((dist_in_meters / 1000).toFixed(0)); - return Number.parseFloat((dist_in_meters / 1000).toPrecision(3)); +const KM_TO_MILES = 0.621371; +/* formatting distances for display: + - if distance >= 100, round to the nearest integer + e.g. "105 mi", "167 km" + - if 1 <= distance < 100, round to 3 significant digits + e.g. "7.02 mi", "11.3 km" + - if distance < 1, round to 2 significant digits + e.g. "0.47 mi", "0.75 km" */ +const formatDistance = (dist: number) => { + if (dist < 1) + return dist.toPrecision(2); + if (dist < 100) + return dist.toPrecision(3); + return Math.round(dist).toString(); } -const getFormattedDistanceInMiles = (dist_in_meters) => - Number.parseFloat((KM_TO_MILES * getFormattedDistanceInKm(dist_in_meters)).toFixed(1)); +const getFormattedDistanceInKm = (distInMeters: string) => + formatDistance(Number.parseFloat(distInMeters) / 1000); + +const getFormattedDistanceInMiles = (distInMeters: string) => + formatDistance((Number.parseFloat(distInMeters) / 1000) * KM_TO_MILES); const getKmph = (metersPerSec) => (metersPerSec * 3.6).toFixed(2); @@ -16,8 +29,6 @@ const getKmph = (metersPerSec) => const getMph = (metersPerSecond) => (KM_TO_MILES * Number.parseFloat(getKmph(metersPerSecond))).toFixed(2); -const KM_TO_MILES = 0.621371; - export function useImperialConfig() { const { appConfig, loading } = useAppConfig(); const [useImperial, setUseImperial] = useState(false); diff --git a/www/js/control/AlertBar.jsx b/www/js/control/AlertBar.jsx new file mode 100644 index 000000000..f640317a4 --- /dev/null +++ b/www/js/control/AlertBar.jsx @@ -0,0 +1,26 @@ +import React from "react"; +import { Modal, Snackbar} from 'react-native-paper'; +import { useTranslation } from "react-i18next"; + +const AlertBar = ({visible, setVisible, messageKey}) => { + const { t } = useTranslation(); + const onDismissSnackBar = () => setVisible(false); + + return ( + setVisible(false)}> + { + onDismissSnackBar() + }, + }}> + {t(messageKey)} + + + ); + }; + +export default AlertBar; \ No newline at end of file diff --git a/www/js/control/ControlDataTable.jsx b/www/js/control/ControlDataTable.jsx index 84f6d724e..796b057ec 100644 --- a/www/js/control/ControlDataTable.jsx +++ b/www/js/control/ControlDataTable.jsx @@ -1,12 +1,9 @@ import React from "react"; import { DataTable } from 'react-native-paper'; -import { angularize } from "../angular-react-helper"; -import { array } from "prop-types"; -// Note the camelCase to dash-case conventions when translating to .html files! // val with explicit call toString() to resolve bool values not showing const ControlDataTable = ({ controlData }) => { - // console.log("Printing data trying to tabulate", controlData); + console.log("Printing data trying to tabulate", controlData); return ( //rows require unique keys! @@ -28,11 +25,5 @@ const styles = { borderLeftColor: 'rgba(0,0,0,0.25)', } } -ControlDataTable.propTypes = { - controlData: array - } - -// need call to angularize to let the React and Angular co-mingle - //second argument is "module path" - can access later as ControlDataTable.module -angularize(ControlDataTable, 'ControlDataTable', 'emission.main.control.dataTable'); + export default ControlDataTable; diff --git a/www/js/control/DemographicsSettingRow.jsx b/www/js/control/DemographicsSettingRow.jsx new file mode 100644 index 000000000..ff91d0921 --- /dev/null +++ b/www/js/control/DemographicsSettingRow.jsx @@ -0,0 +1,29 @@ +import React from "react"; +import { getAngularService } from "../angular-react-helper"; +import SettingRow from "./SettingRow"; + +const DemographicsSettingRow = ({ }) => { + + const EnketoDemographicsService = getAngularService('EnketoDemographicsService'); + const EnketoSurveyLaunch = getAngularService('EnketoSurveyLaunch'); + const $rootScope = getAngularService('$rootScope'); + + // copied from /js/survey/enketo/enketo-demographics.js + function openPopover() { + return EnketoDemographicsService.loadPriorDemographicSurvey().then((lastSurvey) => { + return EnketoSurveyLaunch + .launch($rootScope, 'UserProfileSurvey', { + prefilledSurveyResponse: lastSurvey?.data?.xmlResponse, + showBackButton: true, showFormFooterJumpNav: true + }) + .then(result => { + console.log("demographic survey result ", result); + }); + }); + } + + return +}; + +export default DemographicsSettingRow; \ No newline at end of file diff --git a/www/js/control/ExpandMenu.jsx b/www/js/control/ExpandMenu.jsx new file mode 100644 index 000000000..2f8bb8ef1 --- /dev/null +++ b/www/js/control/ExpandMenu.jsx @@ -0,0 +1,33 @@ +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/PopOpCode.jsx b/www/js/control/PopOpCode.jsx new file mode 100644 index 000000000..23368459d --- /dev/null +++ b/www/js/control/PopOpCode.jsx @@ -0,0 +1,49 @@ +import React 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"; + +const PopOpCode = ({visibilityValue, tokenURL, action, setVis}) => { + const { t } = useTranslation(); + const { colors } = useTheme(); + + return ( + setVis(false)} + transparent={true}> + setVis(false)} + style={styles.dialog(colors.elevation.level3)}> + {t("general-settings.qrcode")} + + + {t("general-settings.qrcode-share-title")} + action()} style={styles.button}/> + + + + + + + ) +} +const styles = StyleSheet.create({ + dialog: (surfaceColor) => ({ + backgroundColor: surfaceColor, + margin: 1, + }), + title: + { + alignItems: 'center', + justifyContent: 'center', + }, + content: { + alignItems: 'center', + justifyContent: 'center', + }, + button: { + margin: 'auto', + } + }); + +export default PopOpCode; \ No newline at end of file diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx new file mode 100644 index 000000000..7249e93e0 --- /dev/null +++ b/www/js/control/ProfileSettings.jsx @@ -0,0 +1,351 @@ +import React, { useState, useEffect } from "react"; +import { Modal, StyleSheet } from "react-native"; +import { Dialog, Button, useTheme } from "react-native-paper"; +import { angularize, 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"; + +let controlUpdateCompleteListenerRegistered = false; + +//any pure functions can go outside +const ProfileSettings = () => { + // anything that mutates must go in --- depend on props or state... + const { t } = useTranslation(); + const { appConfig, loading } = useAppConfig(); + const { colors } = useTheme(); + + // get the scope of the general-settings.js file + const mainControlEl = document.getElementById('main-control').querySelector('ion-view'); + const settingsScope = angular.element(mainControlEl).scope(); + // grab any variables or functions we need from it like this: + const { settings, logOut, viewPrivacyPolicy, + fixAppStatus, forceSync, openDatePicker, + eraseUserData, refreshScreen, endForceSync, checkConsent, + dummyNotification, invalidateCache, showLog, showSensed, + parseState, userDataSaved, userData, ui_config } = settingsScope; + + //angular services needed + const CarbonDatasetHelper = getAngularService('CarbonDatasetHelper'); + const UploadHelper = getAngularService('UploadHelper'); + const EmailHelper = getAngularService('EmailHelper'); + const ControlCollectionHelper = getAngularService('ControlCollectionHelper'); + const ControlSyncHelper = getAngularService('ControlSyncHelper'); + const CalorieCal = getAngularService('CalorieCal'); + const KVStore = getAngularService('KVStore'); + const NotificationScheduler = getAngularService('NotificationScheduler'); + + if (!controlUpdateCompleteListenerRegistered) { + settingsScope.$on('control.update.complete', function() { + console.debug("Received control.update.complete event, refreshing screen"); + refreshScreen(); + refreshCollectSettings(); + }); + controlUpdateCompleteListenerRegistered = true; + } + + //functions that come directly from an Angular service + const editCollectionConfig = ControlCollectionHelper.editConfig; + const editSyncConfig = ControlSyncHelper.editConfig; + + //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 [collectSettings, setCollectSettings] = useState({}); + const [notificationSettings, setNotificationSettings] = useState({}); + + 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(() => { + if (appConfig) { + refreshCollectSettings(); + refreshNotificationSettings(); + } + }, [appConfig]); + + async function refreshCollectSettings() { + console.debug('about to refreshCollectSettings, collectSettings = ', collectSettings); + const newCollectSettings = {}; + + // refresh collect plugin configuration + const collectionPluginConfig = await ControlCollectionHelper.getCollectionSettings(); + newCollectSettings.config = collectionPluginConfig; + + const collectionPluginState = await ControlCollectionHelper.getState(); + newCollectSettings.state = collectionPluginState; + newCollectSettings.trackingOn = collectionPluginState != "local.state.tracking_stopped" + && collectionPluginState != "STATE_TRACKING_STOPPED"; + + // I am not sure that this is actually needed anymore since https://github.com/e-mission/e-mission-data-collection/commit/92f41145e58c49e3145a9222a78d1ccacd16d2a7 + const geofenceConfig = await KVStore.get("OP_GEOFENCE_CFG"); + newCollectSettings.experimentalGeofenceOn = geofenceConfig != null; + + const isLowAccuracy = ControlCollectionHelper.isMediumAccuracy(); + if (typeof isLowAccuracy != 'undefined') { + newCollectSettings.lowAccuracy = isLowAccuracy; + } + + setCollectSettings(newCollectSettings); + } + + async function refreshNotificationSettings() { + console.debug('about to refreshNotificationSettings, notificationSettings = ', notificationSettings); + const newNotificationSettings ={}; + + if (ui_config?.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); + } + + //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(); + }); + } + } + + async function userStartStopTracking() { + const transitionToForce = collectSettings.trackingOn ? 'STOP_TRACKING' : 'START_TRACKING'; + ControlCollectionHelper.forceTransition(transitionToForce); + /* the ControlCollectionHelper.forceTransition call above will trigger a + 'control.update.complete' event when it's done, which will trigger refreshCollectSettings. + So we don't need to call refreshCollectSettings here. */ + } + + const toggleLowAccuracy = function() { + ControlCollectionHelper.toggleLowAccuracy(); + refreshCollectSettings(); + } + + const shareQR = function() { + var prepopulateQRMessage = {}; + var qrAddress = "emission://login_token?token="+settings?.auth?.opcode; + prepopulateQRMessage.files = [qrAddress]; + prepopulateQRMessage.url = settings.auth.opcode; + + 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); + }); + } + + const viewQRCode = function(e) { + setOpCodeVis(true); + } + + var prepopulateMessage = { + message: t('general-settings.share-message'), + subject: t('general-settings.share-subject'), + url: t('general-settings.share-url') + } + + const share = function() { + window.plugins.socialsharing.shareWithOptions(prepopulateMessage, 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); + }); + } + + //conditional creation of setting sections + let userDataSection; + if(userDataSaved()) + { + userDataSection = + + + ; + } + + 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 ( + <> + + + + + {timePicker} + + + + setCarbonDataVis(true)}> + + + + {logUploadSection} + + + {userDataSection} + + + + + + + {notifSchedule} + + setNukeVis(true)}> + setForceStateVis(true)}> + + + + + + + console.log("")} desc={settings?.clientAppVer}> + + + {/* menu for "nuke data" */} + setNukeVis(false)} + transparent={true}> + setNukeVis(false)} + style={styles.dialog(colors.elevation.level3)}> + {t('general-settings.clear-data')} + + + + + + + + + + + + {/* menu for "set carbon dataset - only somewhat working" */} + setCarbonDataVis(false)} + transparent={true}> + setCarbonDataVis(false)} + style={styles.dialog(colors.elevation.level3)}> + {t('general-settings.choose-dataset')} + + {carbonOptions.map((e) => + + )} + + + + + + + + {/* force state sheet */} + setForceStateVis(false)} + transparent={true}> + setForceStateVis(false)} + style={styles.dialog(colors.elevation.level3)}> + {"Force State"} + + {stateActions.map((e) => + + )} + + + + + + + + {/* opcode viewing popup */} + + + ); +}; +const styles = StyleSheet.create({ + dialog: (surfaceColor) => ({ + backgroundColor: surfaceColor, + margin: 1, + }), + monoDesc: { + fontSize: 12, + fontFamily: "monospace", + } + }); + + angularize(ProfileSettings, 'ProfileSettings', 'emission.main.control.profileSettings'); + export default ProfileSettings; diff --git a/www/js/control/ReminderTime.tsx b/www/js/control/ReminderTime.tsx new file mode 100644 index 000000000..40e8485ee --- /dev/null +++ b/www/js/control/ReminderTime.tsx @@ -0,0 +1,69 @@ +import React, { useState } from "react"; +import { Modal, StyleSheet } from 'react-native'; +import { List, useTheme } from 'react-native-paper'; +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 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}> + + + ) +} + +const ReminderTime = ({ rowText, timeVar, defaultTime, updateFunc }) => { + const { t } = useTranslation(); + const { colors } = useTheme(); + const [pickTimeVis, setPickTimeVis] = useState(false); + + let rightComponent = ; + + return ( + <> + setPickTimeVis(true)} + right={() => rightComponent} + /> + + + + + ); +}; +const styles = StyleSheet.create({ + item: (surfaceColor) => ({ + justifyContent: 'space-between', + alignContent: 'center', + backgroundColor: surfaceColor, + margin: 1, + }), + }); + +export default ReminderTime; \ No newline at end of file diff --git a/www/js/control/SettingRow.jsx b/www/js/control/SettingRow.jsx new file mode 100644 index 000000000..3caf36d81 --- /dev/null +++ b/www/js/control/SettingRow.jsx @@ -0,0 +1,52 @@ +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, action, desc, switchValue, 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/general-settings.js b/www/js/control/general-settings.js index 0a5fea1ec..023aca98d 100644 --- a/www/js/control/general-settings.js +++ b/www/js/control/general-settings.js @@ -1,8 +1,7 @@ 'use strict'; import angular from 'angular'; -import ControlDataTable from './ControlDataTable'; -import QrCode from '../components/QrCode'; +import ProfileSettings from './ProfileSettings'; angular.module('emission.main.control',['emission.services', 'emission.i18n.utils', @@ -20,8 +19,7 @@ angular.module('emission.main.control',['emission.services', 'emission.survey.enketo.demographics', 'emission.plugin.logger', 'emission.config.dynamic', - QrCode.module, - ControlDataTable.module]) + ProfileSettings.module]) .controller('ControlCtrl', function($scope, $window, $ionicScrollDelegate, $ionicPlatform, @@ -75,17 +73,7 @@ angular.module('emission.main.control',['emission.services', ionicDatePicker.openDatePicker(datepickerObject); }; - $scope.carbonDatasetString = i18next.t('general-settings.carbon-dataset') + ": " + CarbonDatasetHelper.getCurrentCarbonDatasetCode(); - - $scope.uploadLog = function () { - UploadHelper.uploadFile("loggerDB") - }; - - $scope.emailLog = function () { - // Passing true, we want to send logs - EmailHelper.sendEmail("loggerDB") - }; - + //this function used in ProfileSettings to viewPrivacyPolicy $scope.viewPrivacyPolicy = function($event) { // button -> list element -> scroll // const targetEl = $event.currentTarget.parentElement.parentElement; @@ -102,18 +90,7 @@ angular.module('emission.main.control',['emission.services', } } - $scope.viewQRCode = function($event) { - $scope.tokenURL = "emission://login_token?token="+$scope.settings.auth.opcode; - if ($scope.qrp) { - $scope.qrp.show($event); - } else { - $ionicPopover.fromTemplateUrl("templates/control/qrc.html", {scope: $scope}).then((q) => { - $scope.qrp = q; - $scope.qrp.show($event); - }).catch((err) => Logger.displayError("Error while displaying QR Code", err)); - } - } - + //this function used in ProfileSettings to send DummyNotification $scope.dummyNotification = () => { cordova.plugins.notification.local.addActions('dummy-actions', [ { id: 'action', title: 'Yes' }, @@ -128,14 +105,7 @@ angular.module('emission.main.control',['emission.services', }); } - $scope.updatePrefReminderTime = (storeNewVal=true) => { - const m = moment($scope.settings.notification.prefReminderTimeVal); - $scope.settings.notification.prefReminderTime = m.format('LT'); // display in user's locale - if (storeNewVal) - NotificationScheduler.setReminderPrefs({ reminder_time_of_day: m.format('HH:mm') }); // store in HH:mm - $scope.settings.notification.scheduledNotifs = NotificationScheduler.scheduledNotifs; - } - + //called in ProfileSettings on the AppStatus row $scope.fixAppStatus = function() { $scope.$broadcast("recomputeAppStatus"); $scope.appStatusModal.show(); @@ -163,7 +133,7 @@ angular.module('emission.main.control',['emission.services', gender: userDataFromStorage.gender == 1? i18next.t('gender-male') : i18next.t('gender-female') } for (var i in temp) { - $scope.userData.push({key: i, value: temp[i]}); + $scope.userData.push({key: i, val: temp[i]}); //needs to be val for the data table! } } }); @@ -205,20 +175,6 @@ angular.module('emission.main.control',['emission.services', $scope.refreshScreen(); }); }); - $scope.getLowAccuracy = function() { - // return true: toggle on; return false: toggle off. - var isMediumAccuracy = ControlCollectionHelper.isMediumAccuracy(); - if (!angular.isDefined(isMediumAccuracy)) { - // config not loaded when loading ui, set default as false - // TODO: Read the value if it is not defined. - // Otherwise, don't we have a race with reading? - // we don't really $apply on this field... - return false; - } else { - return isMediumAccuracy; - } - } - $scope.toggleLowAccuracy = ControlCollectionHelper.toggleLowAccuracy; $scope.getConnectURL = function() { ControlHelper.getSettings().then(function(response) { @@ -231,14 +187,6 @@ angular.module('emission.main.control',['emission.services', }); }; - $scope.getCollectionSettings = function() { - ControlCollectionHelper.getCollectionSettings().then(function(showConfig) { - $scope.$apply(function() { - $scope.settings.collect.show_config = showConfig; - }) - }); - }; - $scope.getSyncSettings = function() { ControlSyncHelper.getSyncSettings().then(function(showConfig) { $scope.$apply(function() { @@ -261,17 +209,20 @@ angular.module('emission.main.control',['emission.services', Logger.displayError("while getting opcode, ",error); }); }; + //in ProfileSettings in DevZone $scope.showLog = function() { $state.go("root.main.log"); } + //inProfileSettings in DevZone $scope.showSensed = function() { $state.go("root.main.sensed"); } $scope.getState = function() { return ControlCollectionHelper.getState().then(function(response) { - $scope.$apply(function() { - $scope.settings.collect.state = response; - }); + /* collect state is now stored in ProfileSettings' collectSettings */ + // $scope.$apply(function() { + // $scope.settings.collect.state = response; + // }); return response; }, function(error) { Logger.displayError("while getting current state", error); @@ -294,25 +245,7 @@ angular.module('emission.main.control',['emission.services', }); } - $scope.nukeUserCache = function() { - var nukeChoiceActions = [{text: i18next.t('general-settings.nuke-ui-state-only'), - action: KVStore.clearOnlyLocal}, - {text: i18next.t('general-settings.nuke-native-cache-only'), - action: KVStore.clearOnlyNative}, - {text: i18next.t('general-settings.nuke-everything'), - action: KVStore.clearAll}]; - - $ionicActionSheet.show({ - titleText: i18next.t('general-settings.clear-data'), - cancelText: i18next.t('general-settings.cancel'), - buttons: nukeChoiceActions, - buttonClicked: function(index, button) { - button.action(); - return true; - } - }); - } - + //in ProfileSettings in DevZone $scope.invalidateCache = function() { window.cordova.plugins.BEMUserCache.invalidateAllCache().then(function(result) { $scope.$apply(function() { @@ -347,57 +280,28 @@ angular.module('emission.main.control',['emission.services', $scope.refreshScreen(); }); + //in ProfileSettings in DevZone $scope.refreshScreen = function() { console.log("Refreshing screen"); $scope.settings = {}; - $scope.settings.collect = {}; $scope.settings.sync = {}; - $scope.settings.notification = {}; $scope.settings.auth = {}; $scope.settings.connect = {}; $scope.settings.clientAppVer = ClientStats.getAppVersion(); $scope.getConnectURL(); - $scope.getCollectionSettings(); $scope.getSyncSettings(); $scope.getOPCode(); - $scope.getState().then($scope.isTrackingOn).then(function(isTracking) { - $scope.$apply(function() { - console.log("Setting settings.collect.trackingOn = "+isTracking); - $scope.settings.collect.trackingOn = isTracking; - }); - }); - KVStore.get("OP_GEOFENCE_CFG").then(function(storedCfg) { - $scope.$apply(function() { - if (storedCfg == null) { - console.log("Setting settings.collect.experimentalGeofenceOn = false"); - $scope.settings.collect.experimentalGeofenceOn = false; - } else { - console.log("Setting settings.collect.experimentalGeofenceOn = true"); - $scope.settings.collect.experimentalGeofenceOn = true; - } - }); - }); - if ($scope.ui_config.reminderSchemes) { - NotificationScheduler.getReminderPrefs().then((prefs) => { - $scope.$apply(() => { - const m = moment(prefs.reminder_time_of_day, 'HH:mm'); - // defining data used to populate the upcoming display - $scope.settings.notification.scheduledNotifs = NotificationScheduler.scheduledNotifs; - $scope.settings.notification.prefReminderTimeVal = m.toDate(); - $scope.settings.notification.prefReminderTimeOnLoad = prefs.reminder_time_of_day; - $scope.updatePrefReminderTime(false); // update the displayed time - }); - }); - } $scope.getUserData(); }; - $scope.copyToClipboard = (textToCopy) => { - navigator.clipboard.writeText(textToCopy).then(() => { - ionicToast.show('{Copied to clipboard!}', 'bottom', false, 2000); - }); - } + //this feature has been eliminated (as of right now) + // $scope.copyToClipboard = (textToCopy) => { + // navigator.clipboard.writeText(textToCopy).then(() => { + // ionicToast.show('{Copied to clipboard!}', 'bottom', false, 2000); + // }); + // } + //used in ProfileSettings at the profile/logout/opcode row $scope.logOut = function() { $ionicPopup.confirm({ title: i18next.t('general-settings.are-you-sure'), @@ -502,6 +406,7 @@ angular.module('emission.main.control',['emission.services', }) } + //in ProfileSettings in DevZone $scope.endForceSync = function() { /* First, quickly start and end the trip. Let's listen to the promise * result for start so that we ensure ordering */ @@ -515,10 +420,6 @@ angular.module('emission.main.control',['emission.services', }).then($scope.forceSync); } - $scope.forceState = ControlCollectionHelper.forceState; - $scope.editCollectionConfig = ControlCollectionHelper.editConfig; - $scope.editSyncConfig = ControlSyncHelper.editConfig; - $scope.isAndroid = function() { return ionic.Platform.isAndroid(); } @@ -532,34 +433,14 @@ angular.module('emission.main.control',['emission.services', }).then(function(popover) { $scope.syncSettingsPopup = popover; }); - $scope.isTrackingOn = function() { - return $ionicPlatform.ready().then(function() { - if($scope.isAndroid()){ - return $scope.settings.collect.state != "local.state.tracking_stopped"; - } else if ($scope.isIOS()) { - return $scope.settings.collect.state != "STATE_TRACKING_STOPPED"; - } - }); - }; - $scope.userStartStopTracking = function() { - if ($scope.settings.collect.trackingOn){ - return ControlCollectionHelper.forceTransition('STOP_TRACKING'); - } else { - return ControlCollectionHelper.forceTransition('START_TRACKING'); - } - } - $scope.getExpandButtonClass = function() { - return ($scope.expanded)? "icon ion-ios-arrow-up" : "icon ion-ios-arrow-down"; - } - $scope.getUserDataExpandButtonClass = function() { - return ($scope.dataExpanded)? "icon ion-ios-arrow-up" : "icon ion-ios-arrow-down"; - } + //in ProfileSettings in UserData $scope.eraseUserData = function() { CalorieCal.delete().then(function() { $ionicPopup.alert({template: i18next.t('general-settings.user-data-erased')}); }); } + //in ProfileSettings in DevZone -- part of force/edit state $scope.parseState = function(state) { if (state) { if($scope.isAndroid()){ @@ -569,44 +450,20 @@ angular.module('emission.main.control',['emission.services', } } } - $scope.changeCarbonDataset = function() { - $ionicActionSheet.show({ - buttons: CarbonDatasetHelper.getCarbonDatasetOptions(), - titleText: i18next.t('general-settings.choose-dataset'), - cancelText: i18next.t('general-settings.cancel'), - buttonClicked: function(index, button) { - console.log("changeCarbonDataset(): chose locale " + button.value); - CarbonDatasetHelper.saveCurrentCarbonDatasetLocale(button.value); - $scope.carbonDatasetString = i18next.t('general-settings.carbon-dataset') + ": " + CarbonDatasetHelper.getCurrentCarbonDatasetCode(); - return true; - } - }); - }; - $scope.expandDeveloperZone = function() { - if ($scope.collectionExpanded()) { - $scope.expanded = false; - $ionicScrollDelegate.resize(); - $ionicScrollDelegate.scrollTo(0, 0, true); - - } else { - $scope.expanded = true; - $ionicScrollDelegate.resize(); - $ionicScrollDelegate.scrollTo(0, 1000, true); - } - } - $scope.toggleUserData = function() { - if ($scope.dataExpanded) { - $scope.dataExpanded = false; - } else { - $scope.dataExpanded = true; - } - } - $scope.collectionExpanded = function() { - return $scope.expanded; - } - $scope.userDataExpanded = function() { - return $scope.dataExpanded && $scope.userDataSaved(); - } + // //in ProfileSettings change carbon set + // $scope.changeCarbonDataset = function() { + // $ionicActionSheet.show({ + // buttons: CarbonDatasetHelper.getCarbonDatasetOptions(), + // titleText: i18next.t('general-settings.choose-dataset'), + // cancelText: i18next.t('general-settings.cancel'), + // buttonClicked: function(index, button) { + // console.log("changeCarbonDataset(): chose locale " + button.value); + // CarbonDatasetHelper.saveCurrentCarbonDatasetLocale(button.value); + // $scope.carbonDatasetString = i18next.t('general-settings.carbon-dataset') + ": " + CarbonDatasetHelper.getCurrentCarbonDatasetCode(); + // return true; + // } + // }); + // }; var handleNoConsent = function(resultDoc) { $ionicPopup.confirm({template: i18next.t('general-settings.consent-not-found')}) @@ -636,6 +493,7 @@ angular.module('emission.main.control',['emission.services', }); } + //in ProfileSettings in DevZone (above two functions are helpers) $scope.checkConsent = function() { StartPrefs.getConsentDocument().then(function(resultDoc){ if (resultDoc == null) { diff --git a/www/js/diary.js b/www/js/diary.js index c255621c5..8fb257ad8 100644 --- a/www/js/diary.js +++ b/www/js/diary.js @@ -2,7 +2,11 @@ import angular from 'angular'; import LabelTab from './diary/LabelTab'; angular.module('emission.main.diary',['emission.main.diary.services', - 'emission.survey', + 'emission.survey.external.launch', + 'emission.survey.multilabel.buttons', + 'emission.survey.multilabel.infscrollfilters', + 'emission.survey.enketo.add-note-button', + 'emission.survey.enketo.trip.infscrollfilters', 'emission.plugin.logger', LabelTab.module]) diff --git a/www/js/diary/DiaryButton.tsx b/www/js/diary/DiaryButton.tsx index 8a50a8c57..7a095f53e 100644 --- a/www/js/diary/DiaryButton.tsx +++ b/www/js/diary/DiaryButton.tsx @@ -1,9 +1,10 @@ import React from "react"; -import { angularize } from "../angular-react-helper"; import { StyleSheet } from 'react-native'; -import { Button, useTheme } from 'react-native-paper'; +import { Button, ButtonProps, useTheme } from 'react-native-paper'; +import { Icon } from "../components/Icon"; -const DiaryButton = ({ text, fillColor, ...buttonProps } : Props) => { +type Props = ButtonProps & { fillColor?: string }; +const DiaryButton = ({ children, fillColor, icon, ...rest } : Props) => { const { colors } = useTheme(); const style = fillColor ? { color: colors.onPrimary } @@ -12,31 +13,34 @@ const DiaryButton = ({ text, fillColor, ...buttonProps } : Props) => { return ( ); }; -interface Props { - text: string, - fillColor?: string, - [key: string]: any, -} -const buttonStyles = StyleSheet.create({ +const s = StyleSheet.create({ buttonContent: { height: 25, }, label: { - marginLeft: 24, - marginRight: 16, + marginHorizontal: 5, + marginVertical: 0, fontSize: 13, fontWeight: '500', - flex: 1, - placeItems: 'center', whiteSpace: 'nowrap', + }, + icon: { + marginRight: 4, + verticalAlign: 'middle', } }); diff --git a/www/js/diary/LabelDetailsScreen.tsx b/www/js/diary/LabelDetailsScreen.tsx index 5a3385336..6af9bbf0d 100644 --- a/www/js/diary/LabelDetailsScreen.tsx +++ b/www/js/diary/LabelDetailsScreen.tsx @@ -3,128 +3,113 @@ Navigated to from the main LabelListScreen by clicking a trip card. */ import React, { useContext } from "react"; -import { View, ScrollView, StyleSheet, useWindowDimensions } from "react-native"; -import { Appbar, Divider, IconButton, Surface, Text, useTheme } from "react-native-paper"; +import { View, Modal, ScrollView, useWindowDimensions } from "react-native"; +import { Appbar, Divider, Surface, Text, useTheme } from "react-native-paper"; import { LabelTabContext } from "./LabelTab"; import { cardStyles } from "./cards/DiaryCard"; import LeafletView from "../components/LeafletView"; import { useTranslation } from "react-i18next"; import MultilabelButtonGroup from "../survey/multilabel/MultiLabelButtonGroup"; import UserInputButton from "../survey/enketo/UserInputButton"; -import { getAngularService } from "../angular-react-helper"; -import { useImperialConfig } from "../config/useImperialConfig"; import { useAddressNames } from "./addressNamesHelper"; +import { Icon } from "../components/Icon"; +import { SafeAreaView } from "react-native-safe-area-context"; +import useDerivedProperties from "./useDerivedProperties"; +import StartEndLocations from "./StartEndLocations"; const LabelScreenDetails = ({ route, navigation }) => { const { surveyOpt, timelineMap } = useContext(LabelTabContext); - const { getFormattedDistance, distanceSuffix } = useImperialConfig(); const { t } = useTranslation(); const { height: windowHeight } = useWindowDimensions(); const { colors } = useTheme(); - const { tripId } = route.params; - const trip = timelineMap.get(tripId); + const trip = timelineMap.get(route.params.tripId); + const { displayDate, displayStartTime, displayEndTime, + displayTime, formattedDistance, formattedSectionProperties, + distanceSuffix, percentages } = useDerivedProperties(trip); const [ tripStartDisplayName, tripEndDisplayName ] = useAddressNames(trip); const mapOpts = {minZoom: 3, maxZoom: 17}; - - const DiaryHelper = getAngularService('DiaryHelper'); - const sectionsFormatted = DiaryHelper.getFormattedSectionProperties(trip, {getFormattedDistance, distanceSuffix}); - - return (<> - - { navigation.goBack() }} /> - - - - - - {trip.display_start_time} - - - - {tripStartDisplayName} - - - - - - {trip.display_end_time} - - - - {tripEndDisplayName} - - - - - - - - - - {t('diary.distance')} - - - {`${getFormattedDistance(trip.distance)} ${distanceSuffix}`} - - - - - {t('diary.time')} - - - {trip.display_time} - - - - {trip.percentages?.map?.((pct, i) => ( - - + + return ( + + + + { navigation.goBack() }} /> + + + + + + + + + + + + {t('diary.distance')} + + + {`${formattedDistance} ${distanceSuffix}`} + + + + + {t('diary.time')} + - {pct.pct}% + {displayTime} - ))} - - - - {surveyOpt?.elementTag == 'multilabel' && - } - {surveyOpt?.elementTag == 'enketo-trip-button' - && } - - {/* for multi-section trips, show a list of sections */} - {sectionsFormatted?.length > 1 && - - {sectionsFormatted.map((section, i) => ( - - - {section.fmt_time_range} - {section.fmt_time} - - - - {`${section.fmt_distance} ${section.fmt_distance_suffix}`} - - - - - + + {percentages?.map?.((pct, i) => ( + + + + {pct.pct}% + + + ))} + + + + {surveyOpt?.elementTag == 'multilabel' && + } + {surveyOpt?.elementTag == 'enketo-trip-button' + && } + + {/* for multi-section trips, show a list of sections */} + {formattedSectionProperties?.length > 1 && + + {formattedSectionProperties.map((section, i) => ( + + + {section.fmt_time_range} + {section.fmt_time} + + + + {`${section.fmt_distance} ${section.fmt_distance_suffix}`} + + + + + + + ))} - ))} - - } + } - {/* TODO: show speed graph here */} + {/* TODO: show speed graph here */} - - - ) + + + + + ) } export default LabelScreenDetails; diff --git a/www/js/diary/LabelListScreen.tsx b/www/js/diary/LabelListScreen.tsx index 8b50f7e47..3801d88a9 100644 --- a/www/js/diary/LabelListScreen.tsx +++ b/www/js/diary/LabelListScreen.tsx @@ -21,7 +21,7 @@ const LabelListScreen = () => { numListTotal={timelineMap?.size} /> - refresh()} + refresh()} accessibilityLabel="Refresh" style={{marginLeft: 'auto'}} /> diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 27c2fa978..98643f2cc 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -17,8 +17,9 @@ import LabelListScreen from "./LabelListScreen"; import { createStackNavigator } from "@react-navigation/stack"; import LabelScreenDetails from "./LabelDetailsScreen"; import { NavigationContainer } from "@react-navigation/native"; -import { compositeTrips2TimelineMap, getAllUnprocessedInputs, getLocalUnprocessedInputs, populateBasicClasses, populateCompositeTrips } from "./timelineHelper"; +import { compositeTrips2TimelineMap, getAllUnprocessedInputs, getLocalUnprocessedInputs, populateCompositeTrips } from "./timelineHelper"; import { fillLocationNamesOfTrip, resetNominatimLimiter } from "./addressNamesHelper"; +import { SurveyOptions } from "../survey/survey"; let labelPopulateFactory, labelsResultMap, notesResultMap, showPlaces; const ONE_DAY = 24 * 60 * 60; // seconds @@ -44,7 +45,6 @@ const LabelTab = () => { const Logger = getAngularService('Logger'); const Timeline = getAngularService('Timeline'); const CommHelper = getAngularService('CommHelper'); - const SurveyOptions = getAngularService('SurveyOptions'); const enbs = getAngularService('EnketoNotesButtonService'); // initialization, once the appConfig is loaded @@ -238,7 +238,6 @@ const LabelTab = () => { const [newLabels, newNotes] = await getLocalUnprocessedInputs(pipelineRange, labelPopulateFactory, enbs); const repopTime = new Date().getTime(); const newEntry = {...timelineMap.get(oid), justRepopulated: repopTime}; - populateBasicClasses(newEntry); labelPopulateFactory.populateInputsAndInferences(newEntry, newLabels); enbs.populateInputsAndInferences(newEntry, newNotes); const newTimelineMap = new Map(timelineMap).set(oid, newEntry); diff --git a/www/js/diary/StartEndLocations.tsx b/www/js/diary/StartEndLocations.tsx new file mode 100644 index 000000000..75f3ea12a --- /dev/null +++ b/www/js/diary/StartEndLocations.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { View, Text } from 'react-native'; +import { Icon } from '../components/Icon'; +import { Divider, useTheme } from 'react-native-paper'; +import { cardStyles } from './cards/DiaryCard'; + +type Props = { + 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) && <> + + + {props.displayEndTime && + + {props.displayEndTime} + + } + + + + + {props.displayEndName} + + + } + ); +} + +const s = { + location: (centered) => ({ + flexDirection: 'row', + alignItems: 'center', + justifyContent: centered ? 'center' : 'flex-start', + }), + 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, + }) +} + +export default StartEndLocations; diff --git a/www/js/diary/addressNamesHelper.ts b/www/js/diary/addressNamesHelper.ts index 154c853ee..8023ccdab 100644 --- a/www/js/diary/addressNamesHelper.ts +++ b/www/js/diary/addressNamesHelper.ts @@ -144,7 +144,7 @@ export function fillLocationNamesOfTrip(trip) { // a React hook that takes a trip or place and returns an array of its address names export function useAddressNames(tlEntry) { - const [addressNames, setAddressNames] = useState([]); + const [addressNames, setAddressNames] = useState(['', '']); // if a place is passed in, it will need just one address name const [locData] = useLocalStorage(tlEntry.location?.coordinates?.toString(), null); // if a trip is passed in, it needs two address names (start and end locations) diff --git a/www/js/diary/cards/DiaryCard.tsx b/www/js/diary/cards/DiaryCard.tsx index 7e74d05a3..5e0c007ba 100644 --- a/www/js/diary/cards/DiaryCard.tsx +++ b/www/js/diary/cards/DiaryCard.tsx @@ -11,22 +11,25 @@ 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"; export const DiaryCard = ({ timelineEntry, children, flavoredTheme, ...otherProps }) => { - const { height, width } = useWindowDimensions(); + const { width: windowWidth } = useWindowDimensions(); + const { displayStartTime, displayEndTime, + displayStartDateAbbr, displayEndDateAbbr } = useDerivedProperties(timelineEntry); const theme = flavoredTheme || useTheme(); return ( - - - + {children} - - + @@ -50,17 +53,6 @@ export const cardStyles = StyleSheet.create({ paddingHorizontal: 4, marginVertical: 'auto', }, - location: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }, - locationIcon: { - width: 18, - height: 24, - margin: 0, - marginRight: 5, - }, cardFooter: { width: '100%', paddingBottom: 10, diff --git a/www/js/diary/cards/PlaceCard.tsx b/www/js/diary/cards/PlaceCard.tsx index fd21e8126..4d1c0b4b2 100644 --- a/www/js/diary/cards/PlaceCard.tsx +++ b/www/js/diary/cards/PlaceCard.tsx @@ -8,38 +8,38 @@ import React from "react"; import { View, StyleSheet } from 'react-native'; -import { IconButton, Text } from 'react-native-paper'; -import { object } from "prop-types"; +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 { Icon } from "../../components/Icon"; +import useDerivedProperties from "../useDerivedProperties"; +import StartEndLocations from "../StartEndLocations"; -const PlaceCard = ({ place }) => { +type Props = { place: {[key: string]: any} }; +const PlaceCard = ({ place }: Props) => { const { appConfig, loading } = useAppConfig(); + const { displayStartTime, displayEndTime, displayDate } = useDerivedProperties(place); let [ placeDisplayName ] = useAddressNames(place); const flavoredTheme = getTheme('place'); return ( - + {/* date and distance */} - {place.display_date} + {displayDate} {/* place name */} - - - - {placeDisplayName} - - + {/* add note button */} @@ -57,6 +57,10 @@ const PlaceCard = ({ place }) => { }; const s = StyleSheet.create({ + placeCardContent: { + marginTop: 12, + marginBottom: 6, + }, notesButton: { paddingHorizontal: 8, minWidth: 150, @@ -70,8 +74,4 @@ const s = StyleSheet.create({ }, }); -PlaceCard.propTypes = { - place: object, -} - export default PlaceCard; diff --git a/www/js/diary/cards/TimestampBadge.jsx b/www/js/diary/cards/TimestampBadge.tsx similarity index 89% rename from www/js/diary/cards/TimestampBadge.jsx rename to www/js/diary/cards/TimestampBadge.tsx index 94c963a2f..9b32b9b07 100644 --- a/www/js/diary/cards/TimestampBadge.jsx +++ b/www/js/diary/cards/TimestampBadge.tsx @@ -2,11 +2,15 @@ Used in the label screen, on the trip, place, and/or untracked cards */ import React from "react"; -import { angularize } from "../../angular-react-helper"; import { bool, string } from "prop-types"; import { Badge, Text, useTheme } from "react-native-paper"; -const TimestampBadge = ({ lightBg, time, date=null, ...otherProps }) => { +type Props = { + lightBg: boolean, + time: string, + date?: string, +}; +const TimestampBadge = ({ lightBg, time, date, ...otherProps }: Props) => { const { colors } = useTheme(); const bgColor = lightBg ? colors.primaryContainer : colors.primary; const textColor = lightBg ? 'black' : 'white'; diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx index 52467cc5c..4585caf3c 100644 --- a/www/js/diary/cards/TripCard.tsx +++ b/www/js/diary/cards/TripCard.tsx @@ -1,14 +1,12 @@ /* TripCard displays a card with information about a trip, including a map of the trip route, plus buttons for labeling trips and/or surveying the user about the trip. If the trip has not been processed on the server yet, this is a draft trip, and it - will used the greenish/greyish 'draft' theme flavor. + will used the greyish 'draft' theme flavor. */ -import React, { useEffect, useState } from "react"; -import { getAngularService } from "../../angular-react-helper"; +import React, { useContext } from "react"; import { View, useWindowDimensions, StyleSheet } from 'react-native'; -import { Divider, IconButton, Text } from 'react-native-paper'; -import { object } from "prop-types"; +import { Divider, Text, IconButton } from 'react-native-paper'; import LeafletView from "../../components/LeafletView"; import { useTranslation } from "react-i18next"; import MultilabelButtonGroup from "../../survey/multilabel/MultiLabelButtonGroup"; @@ -21,27 +19,26 @@ import { DiaryCard, cardStyles } from "./DiaryCard"; import { useNavigation } from "@react-navigation/native"; import { useImperialConfig } from "../../config/useImperialConfig"; import { useAddressNames } from "../addressNamesHelper"; +import { Icon } from "../../components/Icon"; +import { LabelTabContext } from "../LabelTab"; +import useDerivedProperties from "../useDerivedProperties"; +import StartEndLocations from "../StartEndLocations"; -const TripCard = ({ trip }) => { +type Props = { trip: {[key: string]: any}}; +const TripCard = ({ trip }: Props) => { const { t } = useTranslation(); const { width: windowWidth } = useWindowDimensions(); const { appConfig, loading } = useAppConfig(); - const { getFormattedDistance, distanceSuffix } = useImperialConfig(); + const { displayStartTime, displayEndTime, displayDate, formattedDistance, + distanceSuffix, displayTime, percentages } = useDerivedProperties(trip); let [ tripStartDisplayName, tripEndDisplayName ] = useAddressNames(trip); const navigation = useNavigation(); - - const SurveyOptions = getAngularService('SurveyOptions'); - const [surveyOpt, setSurveyOpt] = useState(null); + const { surveyOpt } = useContext(LabelTabContext); const isDraft = trip.key.includes('UNPROCESSED'); const flavoredTheme = getTheme(isDraft ? 'draft' : undefined); - useEffect(() => { - const surveyOptKey = appConfig?.survey_info?.['trip-labels']; - setSurveyOpt(SurveyOptions[surveyOptKey]); - }, [appConfig, loading]); - function showDetail() { navigation.navigate("label.details", { tripId: trip._id.$oid }); } @@ -51,21 +48,42 @@ const TripCard = ({ trip }) => { 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}} /> - {/* left panel */} + {/* right panel */} + {/* date and distance */} + + {displayDate} + + + {t('diary.distance-in-time', {distance: formattedDistance, distsuffix: distanceSuffix, time: displayTime})} + + + {/* start and end locations */} + + + {/* mode and purpose buttons / survey button */} + {surveyOpt?.elementTag == 'multilabel' && + } + {surveyOpt?.elementTag == 'enketo-trip-button' + && } + + + {/* left panel */} - {trip.percentages?.map?.((pct, i) => ( + {percentages?.map?.((pct, i) => ( - - {pct.pct}% + + {pct.pct}% ))} @@ -77,39 +95,6 @@ const TripCard = ({ trip }) => { } - {/* right panel */} - {/* date and distance */} - - {trip.display_date} - - - {t('diary.distance-in-time', {distance: getFormattedDistance(trip.distance), distsuffix: distanceSuffix, time: trip.display_time})} - - - {/* start and end locations */} - - - - {tripStartDisplayName} - - - - - - - {tripEndDisplayName} - - - - {/* mode and purpose buttons / survey button */} - {surveyOpt?.elementTag == 'multilabel' && - } - {surveyOpt?.elementTag == 'enketo-trip-button' - && } - - {trip.additionsList?.length != 0 && @@ -144,14 +129,14 @@ const s = StyleSheet.create({ }, notesButton: { paddingHorizontal: 8, - paddingVertical: 12, + paddingVertical: 8, minWidth: 150, margin: 'auto', }, rightPanel: { flex: 1, paddingHorizontal: 5, - paddingVertical: 12, + paddingVertical: 8, }, locationText: { fontSize: 12, @@ -159,8 +144,4 @@ const s = StyleSheet.create({ }, }); -TripCard.propTypes = { - trip: object, -} - export default TripCard; diff --git a/www/js/diary/cards/UntrackedTimeCard.tsx b/www/js/diary/cards/UntrackedTimeCard.tsx index fa0648216..d382c583e 100644 --- a/www/js/diary/cards/UntrackedTimeCard.tsx +++ b/www/js/diary/cards/UntrackedTimeCard.tsx @@ -9,48 +9,41 @@ import React from "react"; import { View, StyleSheet } from 'react-native'; -import { Divider, IconButton, Text } from 'react-native-paper'; -import { object } from "prop-types"; +import { Divider, Text } from 'react-native-paper'; import { getTheme } from "../../appTheme"; import { useTranslation } from "react-i18next"; import { DiaryCard, cardStyles } from "./DiaryCard"; import { useAddressNames } from "../addressNamesHelper"; +import { Icon } from "../../components/Icon"; +import useDerivedProperties from "../useDerivedProperties"; +import StartEndLocations from "../StartEndLocations"; -const UntrackedTimeCard = ({ triplike }) => { +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 flavoredTheme = getTheme('untracked'); return ( - - {/* date and distance */} + + {/* date and distance */} - {triplike.display_date} + {displayDate} - {t('diary.untracked-time-range', { start: triplike.display_start_time, end: triplike.display_end_time })} + {t('diary.untracked-time-range', { start: displayStartTime, end: displayEndTime })} - {/* start and end locations */} - - - - {triplikeStartDisplayName} - - - - - - - {triplikeEndDisplayName} - - + {/* start and end locations */} + @@ -70,8 +63,4 @@ const s = StyleSheet.create({ }, }); -UntrackedTimeCard.propTypes = { - triplike: object, -} - export default UntrackedTimeCard; diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index 8b6f0a8e7..88dd9baa5 100644 --- a/www/js/diary/diaryHelper.ts +++ b/www/js/diary/diaryHelper.ts @@ -109,4 +109,52 @@ export function getFormattedTimeRange(beginFmtTime: string, endFmtTime: string) return endMoment.to(beginMoment, true); }; -// the rest is TODO, still in services.js +// Temporary function to avoid repear in getPercentages ret val. +const filterRunning = (mode) => + (mode == 'MotionTypes.RUNNING') ? 'MotionTypes.WALKING' : mode; + +export function getPercentages(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; + }); + + // 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, + icon: motionTypeOf(mode)?.icon, + color: motionTypeOf(mode)?.color || 'black', + pct: Math.round(fract * 100) || '<1' // if rounds to 0%, show <1% + }; + }); + + return sectionPcts; +} + +export function getFormattedSectionProperties(trip, ImperialConfig) { + return trip.sections?.map((s) => ({ + fmt_time: getLocalTimeString(s.start_local_dt), + fmt_time_range: getFormattedTimeRange(s.start_fmt_time, s.end_fmt_time), + fmt_distance: ImperialConfig.getFormattedDistance(s.distance), + fmt_distance_suffix: ImperialConfig.distanceSuffix, + icon: motionTypeOf(s.sensed_mode_str)?.icon, + color: motionTypeOf(s.sensed_mode_str)?.color || "#333", + })); +} + +export function getLocalTimeString(dt) { + if (!dt) return; + /* correcting the date of the processed trips knowing that local_dt months are from 1 -> 12 + and for the moment function they need to be between 0 -> 11 */ + const mdt = { ...dt, month: dt.month-1 }; + return moment(mdt).format("LT"); +} diff --git a/www/js/diary/diaryTypes.ts b/www/js/diary/diaryTypes.ts new file mode 100644 index 000000000..84c34468b --- /dev/null +++ b/www/js/diary/diaryTypes.ts @@ -0,0 +1,72 @@ +/* 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, + percentages: { 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 773379d50..a75e685f7 100644 --- a/www/js/diary/list/DateSelect.tsx +++ b/www/js/diary/list/DateSelect.tsx @@ -59,15 +59,19 @@ const DateSelect = ({ tsRange, loadSpecificWeekFn }) => { }, [setOpen, loadSpecificWeekFn] ); + const dateRangeEnd = dateRange[1] || t('diary.today'); return (<> - setOpen(true)}> + setOpen(true)}> {dateRange[0] && (<> {dateRange[0]} )} - {dateRange[1] || t('diary.today')} + {dateRangeEnd} { const { colors } = useTheme(); diff --git a/www/js/diary/list/TimelineScrollList.tsx b/www/js/diary/list/TimelineScrollList.tsx index 5aff5ce6b..6dfd1e736 100644 --- a/www/js/diary/list/TimelineScrollList.tsx +++ b/www/js/diary/list/TimelineScrollList.tsx @@ -1,13 +1,13 @@ import React from 'react'; import { FlashList } from '@shopify/flash-list'; -import { array, func, object, oneOfType, bool, string } from 'prop-types'; import TripCard from '../cards/TripCard'; import PlaceCard from '../cards/PlaceCard'; import UntrackedTimeCard from '../cards/UntrackedTimeCard'; import { View } from 'react-native'; -import { ActivityIndicator, Banner, IconButton, Text } from 'react-native-paper'; +import { ActivityIndicator, Banner, Text } from 'react-native-paper'; import LoadMoreButton from './LoadMoreButton'; import { useTranslation } from 'react-i18next'; +import { Icon } from '../../components/Icon'; const renderCard = ({ item: listEntry }) => { if (listEntry.origin_key.includes('trip')) { @@ -23,7 +23,14 @@ const separator = () => const bigSpinner = const smallSpinner = -const TimelineScrollList = ({ listEntries, queriedRange, pipelineRange, loadMoreFn, isLoading }) => { +type Props = { + listEntries: any[], + queriedRange: any, + pipelineRange: any, + loadMoreFn: (direction: string) => void, + isLoading: boolean | string +} +const TimelineScrollList = ({ listEntries, queriedRange, pipelineRange, loadMoreFn, isLoading }: Props) => { const { t } = useTranslation(); @@ -44,8 +51,7 @@ const TimelineScrollList = ({ listEntries, queriedRange, pipelineRange, loadMore const noTravelBanner = ( + ({ size }) => }> {t('diary.no-travel')} @@ -83,12 +89,4 @@ const TimelineScrollList = ({ listEntries, queriedRange, pipelineRange, loadMore } } -TimelineScrollList.propTypes = { - listEntries: array, - queriedRange: object, - pipelineRange: object, - loadMoreFn: func, - isLoading: oneOfType([bool, string]) -}; - export default TimelineScrollList; diff --git a/www/js/diary/services.js b/www/js/diary/services.js index eff5d78be..9c89e3725 100644 --- a/www/js/diary/services.js +++ b/www/js/diary/services.js @@ -2,115 +2,11 @@ import angular from 'angular'; import { getFormattedTimeRange, motionTypeOf } from './diaryHelper'; +import { SurveyOptions } from '../survey/survey'; angular.module('emission.main.diary.services', ['emission.plugin.logger', 'emission.services']) -.factory('DiaryHelper', function($http){ - var dh = {}; - - // Temporary function to avoid repear in getPercentages ret val. - var filterRunning = function(mode) { - if (mode == 'MotionTypes.RUNNING') { - return 'MotionTypes.WALKING'; - } else { - return mode; - } - } - dh.getPercentages = function(trip) { - if (!trip.sections?.length) return {}; - // we use a Map here to make it easier to work with the for loop below - let dists = {}; - - var totalDist = 0; - for (var i=0; i b[1] - a[1]).map(e => e[0]); - let sectionPcts = sortedKeys.map(function(mode) { - const fract = dists[mode] / totalDist; - return { - mode: mode, - icon: motionTypeOf(mode)?.icon, - color: motionTypeOf(mode)?.color || 'black', - pct: Math.round(fract * 100) || '<1' // if rounds to 0%, show <1% - }; - }); - - return sectionPcts; - } - - dh.getFormattedSectionProperties = (trip, ImperialConfig) => { - return trip.sections?.map((s) => ({ - fmt_time: dh.getLocalTimeString(s.start_local_dt), - fmt_time_range: getFormattedTimeRange(s.start_fmt_time, s.end_fmt_time), - fmt_distance: ImperialConfig.getFormattedDistance(s.distance), - fmt_distance_suffix: ImperialConfig.distanceSuffix, - icon: motionTypeOf(s.sensed_mode_str)?.icon, - color: motionTypeOf(s.sensed_mode_str)?.color || "#333", - })); - }; - - dh.getLocalTimeString = function (dt) { - if (!dt) return; - //correcting the date of the processed trips knowing that local_dt months are from 1 -> 12 and for the moment function they need to be between 0 -> 11 - let mdt = angular.copy(dt) - mdt.month = mdt.month - 1 - return moment(mdt).format("LT"); - }; - - /* this function was formerly 'CommonGraph.getDisplayName()', - located in 'common/services.js' */ - dh.getNominatimLocName = function(loc_geojson) { - console.log(new Date().toTimeString()+" Getting display name for ", loc_geojson); - const address2Name = function(data) { - const address = data["address"]; - var name = ""; - if (angular.isDefined(address)) { - if (address["road"]) { - name = address["road"]; - //sometimes it occurs that we cannot display street name because they are pedestrian or suburb places so we added them. - } else if (address["pedestrian"]) { - name = address["pedestrian"] - } else if (address["suburb"]) { - name = address["suburb"] - } else if (address["neighbourhood"]) { - name = address["neighbourhood"]; - } - if (address["city"]) { - name = name + ", " + address["city"]; - } else if (address["town"]) { - name = name + ", " + address["town"]; - } else if (address["county"]) { - name = name + ", " + address["county"]; - } - } - console.log(new Date().toTimeString()+"got response, setting display name to "+name); - return name; - } - var url = "https://nominatim.openstreetmap.org/reverse?format=json&lat=" + loc_geojson.coordinates[1] + "&lon=" + loc_geojson.coordinates[0]; - return $http.get(url).then((response) => { - console.log(new Date().toTimeString()+"while reading data from nominatim, status = "+response.status - +" data = "+JSON.stringify(response.data)); - return address2Name(response.data); - }).catch((error) => { - if (!dh.nominatimError) { - dh.nominatimError = error; - Logger.displayError("while reading address data ",error); - } - }); - }; - - return dh; -}) -.factory('Timeline', function(CommHelper, SurveyOptions, DynamicConfig, $http, $ionicLoading, $ionicPlatform, $window, +.factory('Timeline', function(CommHelper, DynamicConfig, $http, $ionicLoading, $ionicPlatform, $window, $rootScope, UnifiedDataLoader, Logger, $injector) { var timeline = {}; // corresponds to the old $scope.data. Contains all state for the current diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index d37b0e03d..f37a1246b 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -1,6 +1,6 @@ import moment from "moment"; import { getAngularService } from "../angular-react-helper"; -import { getFormattedDate, getFormattedDateAbbr, getFormattedTimeRange, isMultiDay } from "./diaryHelper"; +import { getFormattedDate, getFormattedDateAbbr, getFormattedTimeRange, getLocalTimeString, getPercentages, isMultiDay } from "./diaryHelper"; /** * @description Unpacks composite trips into a Map object of timeline items, by id. @@ -30,53 +30,23 @@ export function compositeTrips2TimelineMap(ctList: any[], unpackPlaces?: boolean return timelineEntriesMap; } - -let DiaryHelper; -/** - * @description Fills in 'display' fields for trips and places, such as display_date, display_start_time, etc. - * @param tlEntry A timeline entry, either a trip or a place - */ -export function populateBasicClasses(tlEntry) { - DiaryHelper = DiaryHelper || getAngularService('DiaryHelper'); - const beginFmt = tlEntry.start_fmt_time || tlEntry.enter_fmt_time; - const endFmt = tlEntry.end_fmt_time || tlEntry.exit_fmt_time; - 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); - tlEntry.display_date = getFormattedDate(beginFmt, endFmt); - tlEntry.display_start_time = DiaryHelper.getLocalTimeString(beginDt); - tlEntry.display_end_time = DiaryHelper.getLocalTimeString(endDt); - if (tlEntryIsMultiDay) { - tlEntry.display_start_date_abbr = getFormattedDateAbbr(beginFmt); - tlEntry.display_end_date_abbr = getFormattedDateAbbr(endFmt); - } - tlEntry.display_time = getFormattedTimeRange(beginFmt, endFmt); - tlEntry.percentages = DiaryHelper.getPercentages(tlEntry); - // Pre-populate start and end names with   so they take up the same amount of vertical space in the UI before they are populated with real data - tlEntry.start_display_name = "\xa0"; - tlEntry.end_display_name = "\xa0"; -} - export function populateCompositeTrips(ctList, showPlaces, labelsFactory, labelsResultMap, notesFactory, notesResultMap) { ctList.forEach((ct, i) => { if (showPlaces && ct.start_confirmed_place) { const cp = ct.start_confirmed_place; cp.getNextEntry = () => ctList[i]; - populateBasicClasses(cp); 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]; - populateBasicClasses(cp); labelsFactory.populateInputsAndInferences(cp, labelsResultMap); notesFactory.populateInputsAndInferences(cp, notesResultMap); ct.getNextEntry = () => cp; } else { ct.getNextEntry = () => ctList[i + 1]; } - populateBasicClasses(ct); labelsFactory.populateInputsAndInferences(ct, labelsResultMap); notesFactory.populateInputsAndInferences(ct, notesResultMap); }); diff --git a/www/js/diary/useDerivedProperties.tsx b/www/js/diary/useDerivedProperties.tsx new file mode 100644 index 000000000..f929544af --- /dev/null +++ b/www/js/diary/useDerivedProperties.tsx @@ -0,0 +1,31 @@ +import { useMemo } from "react"; +import { useImperialConfig } from "../config/useImperialConfig"; +import { getFormattedDate, getFormattedDateAbbr, getFormattedSectionProperties, getFormattedTimeRange, getLocalTimeString, getPercentages, isMultiDay } from "./diaryHelper"; + +const useDerivedProperties = (tlEntry) => { + + const imperialConfig = useImperialConfig(); + + return useMemo(() => { + const beginFmt = tlEntry.start_fmt_time || tlEntry.enter_fmt_time; + const endFmt = tlEntry.end_fmt_time || tlEntry.exit_fmt_time; + const beginDt = tlEntry.start_local_dt || tlEntry.enter_local_dt; + const endDt = tlEntry.end_local_dt || tlEntry.exit_local_dt; + const tlEntryIsMultiDay = isMultiDay(beginFmt, endFmt); + + return { + displayDate: getFormattedDate(beginFmt, endFmt), + displayStartTime: getLocalTimeString(beginDt), + displayEndTime: getLocalTimeString(endDt), + displayTime: getFormattedTimeRange(beginFmt, endFmt), + displayStartDateAbbr: tlEntryIsMultiDay ? getFormattedDateAbbr(beginFmt) : null, + displayEndDateAbbr: tlEntryIsMultiDay ? getFormattedDateAbbr(endFmt) : null, + formattedDistance: imperialConfig.getFormattedDistance(tlEntry.distance), + formattedSectionProperties: getFormattedSectionProperties(tlEntry, imperialConfig), + distanceSuffix: imperialConfig.distanceSuffix, + percentages: getPercentages(tlEntry), + } + }, [tlEntry, imperialConfig]); +} + +export default useDerivedProperties; diff --git a/www/js/metrics-mappings.js b/www/js/metrics-mappings.js index a9f0a47bb..1826b8a16 100644 --- a/www/js/metrics-mappings.js +++ b/www/js/metrics-mappings.js @@ -1,7 +1,7 @@ import angular from 'angular'; +import { getLabelOptions } from './survey/multilabel/confirmHelper'; angular.module('emission.main.metrics.mappings', ['emission.plugin.logger', - 'emission.survey.multilabel.services', 'emission.plugin.kvstore', "emission.config.dynamic"]) @@ -314,7 +314,7 @@ angular.module('emission.main.metrics.mappings', ['emission.plugin.logger', return standardMETs; } }) -.factory('CustomDatasetHelper', function(ConfirmHelper, METDatasetHelper, Logger, $ionicPlatform, DynamicConfig) { +.factory('CustomDatasetHelper', function(METDatasetHelper, Logger, $ionicPlatform, DynamicConfig) { var cdh = {}; cdh.getCustomMETs = function() { @@ -329,7 +329,7 @@ angular.module('emission.main.metrics.mappings', ['emission.plugin.logger', cdh.populateCustomMETs = function() { let standardMETs = METDatasetHelper.getStandardMETs(); - let modeOptions = cdh.inputParams["MODE"].options; + let modeOptions = cdh.inputParams["MODE"]; let modeMETEntries = modeOptions.map((opt) => { if (opt.met_equivalent) { let currMET = standardMETs[opt.met_equivalent]; @@ -358,7 +358,7 @@ angular.module('emission.main.metrics.mappings', ['emission.plugin.logger', }; cdh.populateCustomFootprints = function() { - let modeOptions = cdh.inputParams["MODE"].options; + let modeOptions = cdh.inputParams["MODE"]; let modeCO2PerMeter = modeOptions.map((opt) => { if (opt.range_limit_km) { if (cdh.range_limited_motorized) { @@ -380,7 +380,7 @@ angular.module('emission.main.metrics.mappings', ['emission.plugin.logger', cdh.init = function(newConfig) { try { - ConfirmHelper.inputParamsPromise.then((inputParams) => { + getLabelOptions(newConfig).then((inputParams) => { console.log("Input params = ", inputParams); cdh.inputParams = inputParams; cdh.populateCustomMETs(); diff --git a/www/js/plugin/logger.js b/www/js/plugin/logger.js deleted file mode 100644 index abf74d459..000000000 --- a/www/js/plugin/logger.js +++ /dev/null @@ -1,24 +0,0 @@ -import angular from 'angular'; - -angular.module('emission.plugin.logger', []) - -.factory('Logger', function($window, $ionicPopup) { - var loggerJs = {} - 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; -}); diff --git a/www/js/plugin/logger.ts b/www/js/plugin/logger.ts new file mode 100644 index 000000000..05e684bbc --- /dev/null +++ b/www/js/plugin/logger.ts @@ -0,0 +1,50 @@ +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); + +export const logInfo = (message: string) => + window['Logger'].log(window['Logger'].LEVEL_INFO, message); + +export const logWarn = (message: string) => + 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); + displayErrorMsg(errorMsg, title); +} + +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 || ''); + } + const displayMsg = title ? title + '\n' + errorMsg : errorMsg; + window.alert(displayMsg); + console.error(displayMsg); + window['Logger'].log(window['Logger'].LEVEL_ERROR, displayMsg); +} diff --git a/www/js/splash/notifScheduler.js b/www/js/splash/notifScheduler.js index df89d1bd8..1fccda3e9 100644 --- a/www/js/splash/notifScheduler.js +++ b/www/js/splash/notifScheduler.js @@ -79,6 +79,55 @@ angular.module('emission.splash.notifscheduler', }); } + //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) => { @@ -108,7 +157,7 @@ angular.module('emission.splash.notifscheduler', cordova.plugins.notification.local.schedule(nots, () => { debugGetScheduled("After scheduling"); isScheduling = false; - rs(); + rs(); //scheduling promise resolved here }); }); }); @@ -121,20 +170,27 @@ angular.module('emission.splash.notifscheduler', reminder_time_of_day} = await scheduler.getReminderPrefs(); const scheme = _config.reminderSchemes[reminder_assignment]; const notifTimes = calcNotifTimes(scheme, reminder_join_date, reminder_time_of_day); - 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); - } - }); - } + + 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(); + }) + } + }); + } + }); }); } @@ -175,9 +231,14 @@ angular.module('emission.splash.notifscheduler', } scheduler.setReminderPrefs = async (newPrefs) => { - await CommHelper.updateUser(newPrefs); - update(); - + 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, @@ -191,6 +252,8 @@ angular.module('emission.splash.notifscheduler', reminder_time_of_day }).then(Logger.log("Added reminder prefs to client stats")); }); + + return updatePromise; } $ionicPlatform.ready().then(async () => { diff --git a/www/js/survey/enketo/AddNoteButton.tsx b/www/js/survey/enketo/AddNoteButton.tsx index 465622ccf..dc82b5577 100644 --- a/www/js/survey/enketo/AddNoteButton.tsx +++ b/www/js/survey/enketo/AddNoteButton.tsx @@ -8,21 +8,22 @@ */ import React, { useEffect, useState, useContext } from "react"; -import { angularize, getAngularService } from "../../angular-react-helper"; -import { object, string } from "prop-types"; import DiaryButton from "../../diary/DiaryButton"; import { useTranslation } from "react-i18next"; import moment from "moment"; import { LabelTabContext } from "../../diary/LabelTab"; +import EnketoModal from "./EnketoModal"; -const AddNoteButton = ({ timelineEntry, notesConfig, storeKey }) => { +type Props = { + 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 EnketoSurveyLaunch = getAngularService("EnketoSurveyLaunch"); - const $rootScope = getAngularService("$rootScope"); - useEffect(() => { let newLabel: string; const localeCode = i18n.resolvedLanguage; @@ -76,27 +77,35 @@ const AddNoteButton = ({ timelineEntry, notesConfig, storeKey }) => { function launchAddNoteSurvey() { const surveyName = notesConfig.surveyName; console.log('About to launch survey ', surveyName); - const prefillFields = getPrefillTimes(); - - return EnketoSurveyLaunch - .launch($rootScope, surveyName, { timelineEntry, prefillFields, dataKey: storeKey }) - .then(result => { - if (!result) return; - repopulateTimelineEntry(timelineEntry._id.$oid); - }); + setPrefillTimes(getPrefillTimes()); + setModalVisible(true); }; - return ( - launchAddNoteSurvey()} /> - ); -}; + function onResponseSaved(result) { + if (result) { + console.log('AddNoteButton: response was saved, about to repopulateTimelineEntry; result=', result); + repopulateTimelineEntry(timelineEntry._id.$oid); + } else { + console.error('AddNoteButton: response was not saved, result=', result); + } + } -AddNoteButton.propTypes = { - timelineEntry: object, - notesConfig: object, - storeKey: string, -} + const [prefillTimes, setPrefillTimes] = useState(null); + const [modalVisible, setModalVisible] = useState(false); + + return (<> + launchAddNoteSurvey()}> + {displayLabel} + + setModalVisible(false)} + onResponseSaved={onResponseSaved} + surveyName={notesConfig?.surveyName} + opts={{ timelineEntry, + dataKey: storeKey, + prefillFields: prefillTimes + }} /> + ); +}; export default AddNoteButton; diff --git a/www/js/survey/enketo/AddedNotesList.tsx b/www/js/survey/enketo/AddedNotesList.tsx index 6db967284..d204f45f3 100644 --- a/www/js/survey/enketo/AddedNotesList.tsx +++ b/www/js/survey/enketo/AddedNotesList.tsx @@ -3,23 +3,24 @@ */ import React, { useContext, useState } from "react"; -import { angularize, createScopeWithVars, getAngularService } from "../../angular-react-helper"; -import { array, object } from "prop-types"; import moment from "moment"; -import { Text } from "react-native" -import { DataTable, IconButton } from "react-native-paper"; +import { Text, Modal } from "react-native" +import { 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"; -const AddedNotesList = ({ timelineEntry, additionEntries }) => { +type Props = { + timelineEntry: any, + additionEntries: any[], +} +const AddedNotesList = ({ timelineEntry, additionEntries }: Props) => { - const [rerender, setRerender] = useState(false); const { repopulateTimelineEntry } = useContext(LabelTabContext); - - const DiaryHelper = getAngularService("DiaryHelper"); - const EnketoSurveyLaunch = getAngularService("EnketoSurveyLaunch"); - const $rootScope = getAngularService("$rootScope"); - const $ionicPopup = getAngularService("$ionicPopup"); + 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 @@ -54,43 +55,40 @@ const AddedNotesList = ({ timelineEntry, additionEntries }) => { .putMessage(dataKey, data) .then(() => { additionEntries.splice(index, 1); - setRerender(!rerender); // force rerender + setConfirmDeleteModalVisible(false); + setEditingEntry(null); }); } function confirmDeleteEntry(entry) { - const currEntry = entry; - const scope = createScopeWithVars({currEntry}); - $ionicPopup.show({ - title: 'Delete entry', - templateUrl: `templates/survey/enketo/delete-entry.html`, - scope, - buttons: [{ - text: 'Delete', // TODO i18n - type: 'button-cancel', - onTap: () => deleteEntry(entry) - }, { - text: 'Cancel', // TODO i18n - type: 'button-stable', - }] - }); + setEditingEntry(entry); + setConfirmDeleteModalVisible(true); + } + + function dismissConfirmDelete() { + setEditingEntry(null); + setConfirmDeleteModalVisible(false); } function editEntry(entry) { - const prevResponse = entry.data.xmlResponse; - const dataKey = entry.key || entry.metadata.key; - const surveyName = entry.data.name; - return EnketoSurveyLaunch - .launch($rootScope, surveyName, { prefilledSurveyResponse: prevResponse, dataKey, timelineEntry }) - .then(result => { - if (!result) return; - repopulateTimelineEntry(timelineEntry._id.$oid); - deleteEntry(entry); - }); + setEditingEntry(entry); + setSurveyModalVisible(true); + } + + async function onEditedResponse(response) { + if (!response) return; + await deleteEntry(editingEntry); + setEditingEntry(null); + repopulateTimelineEntry(timelineEntry._id.$oid); + } + + function onModalDismiss() { + setEditingEntry(null); + setSurveyModalVisible(false); } const sortedEntries = additionEntries?.sort((a, b) => a.data.start_ts - b.data.start_ts); - return ( + return (<> {sortedEntries?.map((entry, index) => { const isLastRow = (index == additionEntries.length - 1); @@ -109,13 +107,34 @@ const AddedNotesList = ({ timelineEntry, additionEntries }) => { confirmDeleteEntry(entry)} style={[styles.cell, {flex: 1}]}> - + ) })} - ); + + + + Are you sure you wish to delete this entry? + + {editingEntry?.data?.label} + {editingEntry?.displayDt?.date} + {editingEntry?.displayDt?.time} + + + + + + + + ); }; const styles:any = { @@ -131,9 +150,4 @@ const styles:any = { }, } -AddedNotesList.propTypes = { - timelineEntry: object, - additionEntries: array, -}; - export default AddedNotesList; diff --git a/www/js/survey/enketo/EnketoModal.tsx b/www/js/survey/enketo/EnketoModal.tsx new file mode 100644 index 000000000..326f8069b --- /dev/null +++ b/www/js/survey/enketo/EnketoModal.tsx @@ -0,0 +1,156 @@ +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 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'; + +type Props = ModalProps & { + 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 enketoForm = useRef
(null); + const { appConfig, loading } = 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; + } + } + + // init logic: retrieve form -> inject into DOM -> initialize Enketo -> show modal + function initSurvey() { + console.debug('Loading survey', surveyName); + const formPath = appConfig.survey_info?.surveys?.[surveyName]?.formPath; + if (!formPath) return console.error('No form path found for survey', surveyName); + + fetchSurveyJson(formPath).then(({ form, model }) => { + surveyJson.current = { form, model }; + 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 + instanceStr: getInstanceStr(model, opts), // existing XML instance (if any), may be a previous response or a pre-filled model + /* There are a few other opts that can be passed to Enketo Core. + We don't use these now, but we may want them later: https://github.com/enketo/enketo-core#usage-as-a-library */ + }; + const currLang = i18n.resolvedLanguage || 'en'; + enketoForm.current = new Form(formEl, data, { language: currLang }); + enketoForm.current.init(); + }); + } + + useEffect(() => { + if (!rest.visible) return; + if (!appConfig || loading) return console.error('App config not loaded yet'); + initSurvey(); + }, [appConfig, loading, 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 ( + {/* this element can be placed anywhere, leaving it out will prevent running ToC-generating code */} +
    +
    + + + + {/* 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
    + + {/*
      */} +
      +
      +
      + ); + + return ( + + + + +
      + {enketoContent} +
      +
      +
      +
      +
      + ); +} + +const s = StyleSheet.create({ + dismissBtn: { + height: 38, + fontSize: 11, + color: '#222', + marginRight: 'auto', + 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 3fcfce336..d3d87d8bb 100644 --- a/www/js/survey/enketo/UserInputButton.tsx +++ b/www/js/survey/enketo/UserInputButton.tsx @@ -9,23 +9,27 @@ */ import React, { useEffect, useState } from "react"; -import { angularize, getAngularService } from "../../angular-react-helper"; -import { object } from "prop-types"; +import { getAngularService } from "../../angular-react-helper"; import DiaryButton from "../../diary/DiaryButton"; import { useTranslation } from "react-i18next"; import { useTheme } from "react-native-paper"; +import { logDebug } from "../../plugin/logger"; +import EnketoModal from "./EnketoModal"; -const UserInputButton = ({ timelineEntry }) => { +type Props = { + timelineEntry: any, +} +const UserInputButton = ({ timelineEntry }: Props) => { const { colors } = useTheme(); const { t, i18n } = useTranslation(); // initial label "Add Trip Details"; will be filled after a survey response is recorded const [displayLabel, setDisplayLabel] = useState(t('diary.choose-survey')); const [isFilled, setIsFilled] = useState(false); + const [prevSurveyResponse, setPrevSurveyResponse] = useState(null); + const [modalVisible, setModalVisible] = useState(false); - const EnketoSurveyLaunch = getAngularService("EnketoSurveyLaunch"); const EnketoTripButtonService = getAngularService("EnketoTripButtonService"); - const $rootScope = getAngularService("$rootScope"); const etbsSingleKey = EnketoTripButtonService.SINGLE_KEY; useEffect(() => { @@ -37,40 +41,39 @@ const UserInputButton = ({ timelineEntry }) => { }, []); function launchUserInputSurvey() { - const 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. */ - console.log('About to launch survey ', surveyName); - + logDebug('UserInputButton: About to launch survey'); const prevResponse = timelineEntry.userInput?.[etbsSingleKey]; - const prefilledSurveyResponse = prevResponse?.data?.xmlResponse; - return EnketoSurveyLaunch - .launch($rootScope, surveyName, { timelineEntry, prefilledSurveyResponse }) - .then(result => { - if (!result) { - return; - } - timelineEntry.userInput[etbsSingleKey] = { - data: result, - write_ts: Date.now() - } - setDisplayLabel(result.label); - setIsFilled(true); - }); - }; + setPrevSurveyResponse(prevResponse?.data?.xmlResponse); + setModalVisible(true); + } - return ( - launchUserInputSurvey()} /> - ); -}; + function onResponseSaved(result) { + if (!result) return; + timelineEntry.userInput[etbsSingleKey] = { + data: result, + write_ts: Date.now() + } + setDisplayLabel(result.label); + setIsFilled(true); + } -UserInputButton.propTypes = { - timelineEntry: object, - notesConfig: object, -} + return (<> + launchUserInputSurvey()}> + {displayLabel} + + + 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 + }} /> + ); +}; -angularize(UserInputButton, 'UserInputButton', 'emission.survey.userinputbutton'); export default UserInputButton; diff --git a/www/js/survey/enketo/enketoHelper.ts b/www/js/survey/enketo/enketoHelper.ts new file mode 100644 index 000000000..398a2ed97 --- /dev/null +++ b/www/js/survey/enketo/enketoHelper.ts @@ -0,0 +1,92 @@ +import { getAngularService } from "../../angular-react-helper"; +import { Form } from 'enketo-core'; +import { XMLParser } from 'fast-xml-parser'; +import i18next from 'i18next'; + +export type PrefillFields = {[key: string]: string}; + +export type SurveyOptions = { + timelineEntry?: any; + prefilledSurveyResponse?: string; + prefillFields?: PrefillFields; + dataKey?: string; +}; + +/** + * @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 + * @returns serialized XML of the prefilled model response + */ +function getXmlWithPrefills(xmlModel: string, prefillFields: PrefillFields) { + if (!prefillFields) return null; + const xmlParser = new window.DOMParser(); + const xmlDoc = xmlParser.parseFromString(xmlModel, 'text/xml'); + + for (const [tagName, value] of Object.entries(prefillFields)) { + const vals = xmlDoc.getElementsByTagName(tagName); + vals[0].innerHTML = value; + } + const instance = xmlDoc.getElementsByTagName('instance')[0].children[0]; + return new XMLSerializer().serializeToString(instance); +} + +/** + * @param xmlModel the blank XML model response for the survey + * @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 { + if (!xmlModel) return null; + if (opts.prefilledSurveyResponse) + return opts.prefilledSurveyResponse; + if (opts.prefillFields) + return getXmlWithPrefills(xmlModel, opts.prefillFields); + return null; +} + +/** + * @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) { + const EnketoSurveyAnswer = getAngularService('EnketoSurveyAnswer'); + 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 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."); + } + // 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); +} diff --git a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx index c44a60422..bd28f9fcb 100644 --- a/www/js/survey/multilabel/MultiLabelButtonGroup.tsx +++ b/www/js/survey/multilabel/MultiLabelButtonGroup.tsx @@ -2,35 +2,39 @@ 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 } from "react"; -import { angularize, createScopeWithVars, getAngularService } from "../../angular-react-helper"; -import { object, number } from "prop-types"; -import { View } from "react-native"; -import { IconButton, Text, useTheme } from "react-native-paper"; +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 "../../diary/DiaryButton"; import { useTranslation } from "react-i18next"; import { LabelTabContext } from "../../diary/LabelTab"; +import { displayErrorMsg, logDebug } from "../../plugin/logger"; +import { getLabelInputDetails, getLabelInputs, getLabelOptions } from "./confirmHelper"; const MultilabelButtonGroup = ({ trip }) => { const { colors } = useTheme(); const { t } = useTranslation(); const { repopulateTimelineEntry } = useContext(LabelTabContext); + const { height: windowHeight } = useWindowDimensions(); const [ inputParams, setInputParams ] = useState({}); - let closePopover; - - const ConfirmHelper = getAngularService("ConfirmHelper"); - const $ionicPopup = getAngularService("$ionicPopup"); - const $ionicPopover = getAngularService("$ionicPopover"); + // 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(() => { + if (otherLabel != null) return 'other'; + return trip.userInput[modalVisibleFor]?.value + }, [modalVisibleFor, otherLabel]); useEffect(() => { console.log("During initialization, trip is ", trip); - ConfirmHelper.inputParamsPromise.then((ip) => setInputParams(ip)); + getLabelOptions().then((ip) => setInputParams(ip)); }, []); // to mark 'inferred' labels as 'confirmed'; turn yellow labels blue function verifyTrip() { - for (const inputType of ConfirmHelper.INPUTS) { + for (const inputType of getLabelInputs()) { const inferred = trip.finalInference[inputType]; // TODO: figure out what to do with "other". For now, do not verify. if (inferred?.value && !trip.userInput[inputType] && inferred.value != "other") @@ -38,39 +42,22 @@ const MultilabelButtonGroup = ({ trip }) => { } } - function openPopover(e, inputType) { - let popoverPath = 'templates/diary/'+inputType.toLowerCase()+'-popover.html'; - const scope = createScopeWithVars({inputParams, choose}); - $ionicPopover.fromTemplateUrl(popoverPath, {scope}).then((pop) => { - closePopover = () => pop.hide(); - pop.show(e); - }); - } - - function choose(inputType, chosenLabel, wasOther=false) { - if (chosenLabel && chosenLabel != "other") { - store(inputType, chosenLabel, wasOther); + function onChooseLabel(chosenValue) { + logDebug(`onChooseLabel with chosen ${modalVisibleFor} as ${chosenValue}`); + if (chosenValue == 'other') { + setOtherLabel(''); } else { - const scope = createScopeWithVars({other: {text: ''}}); - $ionicPopup.show({ - scope, - title: t("trip-confirm.services-please-fill-in", { text: inputType.toLowerCase() }), - template: '', - buttons: [ - { - text: t('trip-confirm.services-cancel') - }, - { - text: `${t('trip-confirm.services-save')}`, - type: 'button-positive', - onTap: () => choose(inputType, scope.other.text, true) - } - ] - }); + store(modalVisibleFor, chosenValue, false); } - }; + } + + function dismiss() { + setModalVisibleFor(null); + 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) */ @@ -82,16 +69,16 @@ const MultilabelButtonGroup = ({ trip }) => { "label": chosenLabel, }; - const storageKey = ConfirmHelper.inputDetails[inputType].key; + const storageKey = getLabelInputDetails()[inputType].key; window['cordova'].plugins.BEMUserCache.putMessage(storageKey, inputDataToStore).then(() => { - closePopover?.(); + dismiss(); repopulateTimelineEntry(trip._id.$oid); - console.debug("Successfully stored input data "+JSON.stringify(inputDataToStore)); + logDebug("Successfully stored input data "+JSON.stringify(inputDataToStore)); }); } const inputKeys = Object.keys(trip.inputDetails); - return ( + return (<> {inputKeys.map((key, i) => { @@ -109,9 +96,10 @@ const MultilabelButtonGroup = ({ trip }) => { return ( {t(input.labeltext)} - openPopover(e, input.name)} - fillColor={fillColor} /> + setModalVisibleFor(input.name)}> + { t(btnText) } + ) })} @@ -122,13 +110,37 @@ const MultilabelButtonGroup = ({ trip }) => { style={{width: 20, height: 20, margin: 3}}/> - ); + 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}> + {inputParams?.[modalVisibleFor]?.map((o, i) => ( + // @ts-ignore + + ))} + + + + {otherLabel != null && <> + setOtherLabel(t)} /> + + + + } + + + + ); }; -MultilabelButtonGroup.propTypes = { - trip: object, - recomputeDelay: number, -} - -angularize(MultilabelButtonGroup, 'MultilabelButtonGroup', 'emission.main.diary.multilabelbtngroup'); export default MultilabelButtonGroup; diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts new file mode 100644 index 000000000..d5e43b826 --- /dev/null +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -0,0 +1,116 @@ +// 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, + } +}; +type LabelOptions = { + [k in T]: { + key: string, + met?: {range: any[], mets: number} + met_equivalent: string, + co2PerMeter: number, + } +} & { translations: { + [lang: string]: { [translationKey: string]: string } +}}; + +let appConfig; +let labelOptions: LabelOptions<'MODE'|'PURPOSE'|'REPLACED_MODE'>; +let inputDetails: InputDetails<'MODE'|'PURPOSE'|'REPLACED_MODE'>; + +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.key; + const translation = labelOptions.translations[lang][translationKey]; + labelOptions[opt][i].text = translation; + }); + } + } else { + // backwards compat: if dynamic config doesn't have label_options, use the old way + const i18nUtils = getAngularService("i18nUtils"); + const optionFileName = await i18nUtils.geti18nFileName("json/", "trip_confirm_options", ".json"); + try { + const optionFile = await fetchUrlCached(optionFileName); + labelOptions = JSON.parse(optionFile) as LabelOptions; + } catch (e) { + logDebug("error "+JSON.stringify(e)+" while reading confirm options, reverting to defaults"); + const optionFile = await fetchUrlCached("json/trip_confirm_options.json.sample"); + labelOptions = JSON.parse(optionFile) as LabelOptions; + } + } + return labelOptions; +} + +export const baseLabelInputDetails = { + MODE: { + 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", + }, +} + +export function getLabelInputDetails(appConfigParam?) { + if (appConfigParam) appConfig = appConfigParam; + if (inputDetails) return inputDetails; + + if (appConfig.intro.program_or_study != 'program') { + // if this is a study, just return the base input details + return baseLabelInputDetails; + } + // else this is a program, so add the REPLACED_MODE + inputDetails = { ...baseLabelInputDetails, + 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); + +const otherValueToText = (otherValue) => { + const words = otherValue.replace("_", " ").split(" "); + if (words.length == 0) return ""; + return words.map((word) => + word[0].toUpperCase() + word.slice(1) + ).join(" "); +} + +const otherTextToValue = (otherText) => + otherText.toLowerCase().replace(" ", "_"); + +export const getFakeEntry = (otherValue) => ({ + text: otherValueToText(otherValue), + value: otherValue, +}); diff --git a/www/js/survey/multilabel/infinite_scroll_filters.js b/www/js/survey/multilabel/infinite_scroll_filters.js index 12ab3e366..bc588ecc2 100644 --- a/www/js/survey/multilabel/infinite_scroll_filters.js +++ b/www/js/survey/multilabel/infinite_scroll_filters.js @@ -11,10 +11,9 @@ import angular from 'angular'; angular.module('emission.survey.multilabel.infscrollfilters',[ - 'emission.survey.multilabel.services', 'emission.plugin.logger' ]) -.factory('MultiLabelInfScrollFilters', function(Logger, ConfirmHelper){ +.factory('MultiLabelInfScrollFilters', function(Logger){ var sf = {}; var unlabeledCheck = function(t) { return t.INPUTS diff --git a/www/js/survey/multilabel/multi-label-ui.js b/www/js/survey/multilabel/multi-label-ui.js index 886e88684..0db17b44b 100644 --- a/www/js/survey/multilabel/multi-label-ui.js +++ b/www/js/survey/multilabel/multi-label-ui.js @@ -1,17 +1,18 @@ import angular from 'angular'; +import { baseLabelInputDetails, getBaseLabelInputs, getFakeEntry, getLabelInputDetails, getLabelInputs, getLabelOptions } from './confirmHelper'; angular.module('emission.survey.multilabel.buttons', - ['emission.survey.multilabel.services', - 'emission.stats.clientstats', + ['emission.stats.clientstats', 'emission.survey.inputmatcher']) -.factory("MultiLabelService", function($rootScope, ConfirmHelper, InputMatcher, $timeout, $ionicPlatform, DynamicConfig, Logger) { +.factory("MultiLabelService", function($rootScope, InputMatcher, $timeout, $ionicPlatform, DynamicConfig, Logger) { var mls = {}; console.log("Creating MultiLabelService"); - mls.init = function() { + mls.init = function(config) { Logger.log("About to initialize the MultiLabelService"); - ConfirmHelper.inputParamsPromise.then((inputParams) => mls.inputParams = inputParams); - mls.MANUAL_KEYS = ConfirmHelper.INPUTS.map((inp) => ConfirmHelper.inputDetails[inp].key); + 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"); }; @@ -31,11 +32,11 @@ angular.module('emission.survey.multilabel.buttons', mls.processManualInputs = function(manualResults, resultMap) { var mrString = 'unprocessed manual inputs ' + manualResults.map(function(item, index) { - return ` ${item.length} ${ConfirmHelper.INPUTS[index]}`; + return ` ${item.length} ${getLabelInputs()[index]}`; }); console.log(mrString); manualResults.forEach(function(mr, index) { - resultMap[ConfirmHelper.INPUTS[index]] = mr; + resultMap[getLabelInputs()[index]] = mr; }); } @@ -44,7 +45,7 @@ angular.module('emission.survey.multilabel.buttons', // console.log("Expectation: "+JSON.stringify(trip.expectation)); // console.log("Inferred labels from server: "+JSON.stringify(trip.inferred_labels)); trip.userInput = {}; - ConfirmHelper.INPUTS.forEach(function(item, index) { + getLabelInputs().forEach(function(item, index) { mls.populateManualInputs(trip, trip.nextTrip, item, manualResultMap[item]); }); @@ -81,11 +82,10 @@ angular.module('emission.survey.multilabel.buttons', 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].value2entry[userInputLabel]; + var userInputEntry = mls.inputParams[inputType].find(o => o.value == userInputLabel); if (!angular.isDefined(userInputEntry)) { - userInputEntry = ConfirmHelper.getFakeEntry(userInputLabel); - mls.inputParams[inputType].options.push(userInputEntry); - mls.inputParams[inputType].value2entry[userInputLabel] = userInputEntry; + userInputEntry = getFakeEntry(userInputLabel); + mls.inputParams[inputType].push(userInputEntry); } console.log("Mapped label "+userInputLabel+" to entry "+JSON.stringify(userInputEntry)); tripField[inputType] = userInputEntry; @@ -111,7 +111,7 @@ angular.module('emission.survey.multilabel.buttons', 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 ConfirmHelper.INPUTS) { + for (const inputType of getLabelInputs()) { const userInput = trip.userInput[inputType]; if (userInput) { const retKey = mls.inputType2retKey(inputType); @@ -121,14 +121,14 @@ angular.module('emission.survey.multilabel.buttons', // Red labels if we have no possibilities left if (labelsList.length == 0) { - for (const inputType of ConfirmHelper.INPUTS) mls.populateInput(trip.finalInference, inputType, undefined); + 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 ConfirmHelper.INPUTS) { + 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(); @@ -165,20 +165,20 @@ angular.module('emission.survey.multilabel.buttons', 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 (ConfirmHelper.isProgram) { - if (inputValue == ConfirmHelper.mode_studied) { - Logger.log("Found "+ConfirmHelper.mode_studied+" mode in a program, displaying full details"); - trip.inputDetails = ConfirmHelper.inputDetails; - trip.INPUTS = ConfirmHelper.INPUTS; + if (mls.ui_config.intro.program_or_study == 'program') { + 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 "+ConfirmHelper.mode_studied+" mode in a program, displaying base details"); - trip.inputDetails = ConfirmHelper.baseInputDetails; - trip.INPUTS = ConfirmHelper.BASE_INPUTS; + 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 = ConfirmHelper.INPUTS; - trip.inputDetails = ConfirmHelper.inputDetails; + trip.INPUTS = getLabelInputs(); + trip.inputDetails = getLabelInputDetails(); } } @@ -186,7 +186,7 @@ angular.module('emission.survey.multilabel.buttons', * MODE (becomes manual/mode_confirm) becomes mode_confirm */ mls.inputType2retKey = function(inputType) { - return ConfirmHelper.inputDetails[inputType].key.split("/")[1]; + return getLabelInputDetails()[inputType].key.split("/")[1]; } mls.updateVerifiability = function(trip) { diff --git a/www/js/survey/multilabel/trip-confirm-services.js b/www/js/survey/multilabel/trip-confirm-services.js deleted file mode 100644 index 4826fc8e1..000000000 --- a/www/js/survey/multilabel/trip-confirm-services.js +++ /dev/null @@ -1,179 +0,0 @@ -import angular from 'angular'; - -angular.module('emission.survey.multilabel.services', ['ionic', 'emission.i18n.utils', - "emission.plugin.logger", "emission.config.dynamic"]) -.factory("ConfirmHelper", function($http, $ionicPopup, $ionicPlatform, i18nUtils, DynamicConfig, Logger) { - var ch = {}; - ch.init = function(ui_config) { - Logger.log("About to start initializing the confirm helper for " + ui_config.intro.program_or_study); - const labelWidth = {"base": "col-50", "intervention": "col-33"}; - const btnWidth = {"base": "115", "intervention": "80"}; - ch.INPUTS = ["MODE", "PURPOSE"]; - ch.inputDetails = { - "MODE": { - name: "MODE", - labeltext: "diary.mode", - choosetext: "diary.choose-mode", - width: labelWidth["base"], - btnWidth: btnWidth["base"], - key: "manual/mode_confirm", - otherVals: {}, - }, - "PURPOSE": { - name: "PURPOSE", - labeltext: "diary.purpose", - choosetext: "diary.choose-purpose", - width: labelWidth["base"], - btnWidth: btnWidth["base"], - key: "manual/purpose_confirm", - otherVals: {}, - } - } - if (ui_config.intro.program_or_study == 'program') { - ch.isProgram = true; - ch.mode_studied = ui_config.intro.mode_studied; - // store a copy of the base input details - ch.baseInputDetails = angular.copy(ch.inputDetails); - ch.BASE_INPUTS = angular.copy(ch.INPUTS); - - // then add the program specific information by adding the REPLACED_MODE - // and resetting the widths - ch.INPUTS.push("REPLACED_MODE"); - for (const [key, value] of Object.entries(ch.inputDetails)) { - value.width = labelWidth["intervention"]; - value.btnWidth = btnWidth["intervention"]; - }; - console.log("Finished resetting label widths ",ch.inputDetails); - ch.inputDetails["REPLACED_MODE"] = { - name: "REPLACED_MODE", - labeltext: "diary.replaces", - choosetext: "diary.choose-replaced-mode", - width: labelWidth["intervention"], - btnWidth: btnWidth["intervention"], - key: "manual/replaced_mode", - otherVals: {} - } - } - Logger.log("Finished initializing ch.INPUTS and ch.inputDetails" + ch.INPUTS); - ch.inputParamsPromise = new Promise(function(resolve, reject) { - const inputParams = {}; - console.log("Starting promise execution with ", inputParams); - const omPromises = ch.INPUTS.map((item) => ch.getOptionsAndMaps(item)); - console.log("Promise list ", omPromises); - Promise.all(omPromises).then((omObjList) => - ch.INPUTS.forEach(function(item, index) { - inputParams[item] = omObjList[index]; - })).catch((err) => { - Logger.displayError("Error while loading input params in "+ch.INPUTS, err) - reject(err); - }); - console.log("Read all inputParams, resolving with ", inputParams); - resolve(inputParams); - }); - Logger.log("Finished creating inputParamsPromise" + ch.inputParamsPromise); - - } - - var fillInOptions = function(confirmConfig) { - if(confirmConfig.data.length == 0) { - throw "blank string instead of missing file on dynamically served app"; - } - ch.INPUTS.forEach(function(i) { - ch.inputDetails[i].options = confirmConfig.data[i] - }); - } - - /* - * Convert the array of {text, value} objects to a {value: text} map so that - * we can look up quickly without iterating over the list for each trip - */ - - var arrayToMap = function(optionsArray) { - var text2entryMap = {}; - var value2entryMap = {}; - - optionsArray.forEach(function(text2val) { - text2entryMap[text2val.text] = text2val; - value2entryMap[text2val.value] = text2val; - }); - return [text2entryMap, value2entryMap]; - } - - var loadAndPopulateOptions = function () { - return i18nUtils.geti18nFileName("json/", "trip_confirm_options", ".json") - .then((optionFileName) => { - console.log("Final option file = "+optionFileName); - return $http.get(optionFileName) - .then(fillInOptions) - .catch(function(err) { - // no prompt here since we have a fallback - console.log("error "+JSON.stringify(err)+" while reading confirm options, reverting to defaults"); - return $http.get("json/trip_confirm_options.json.sample") - .then(fillInOptions) - .catch(function(err) { - // prompt here since we don't have a fallback - Logger.displayError("Error while reading default confirm options", err); - }); - }); - }); - } - - ch.getOptionsAndMaps = function(inputType) { - return ch.getOptions(inputType).then(function(inputOptions) { - console.log("About to map option for inputType"+inputType); - var inputMaps = arrayToMap(inputOptions); - return { - options: inputOptions, - text2entry: inputMaps[0], - value2entry: inputMaps[1] - }; - }); - }; - - /* - * Lazily loads the options and returns the chosen one. Using this option - * instead of an in-memory data structure so that we can return a promise - * and not have to worry about when the data is available. - */ - ch.getOptions = function(inputType) { - if (!angular.isDefined(ch.inputDetails[inputType].options)) { - var lang = i18next.resolvedLanguage; - return loadAndPopulateOptions() - .then(function () { - return ch.inputDetails[inputType].options; - }); - } else { - return Promise.resolve(ch.inputDetails[inputType].options); - } - } - - ch.otherTextToValue = function(otherText) { - return otherText.toLowerCase().replace(" ", "_"); - } - - ch.otherValueToText = function(otherValue) { - var words = otherValue.replace("_", " ").split(" "); - if (words.length == 0) { - return ""; - } - return words.map(function(word) { - return word[0].toUpperCase() + word.slice(1); - }).join(" "); - } - - ch.getFakeEntry = function(otherValue) { - return {text: ch.otherValueToText(otherValue), - value: otherValue}; - } - - $ionicPlatform.ready().then(function() { - Logger.log("UI_CONFIG: about to call configReady function in trip-confirm-services.js"); - DynamicConfig.configReady().then((newConfig) => { - Logger.log("UI_CONFIG: about to call ch.init() with the new config"); - ch.init(newConfig); - }).catch((err) => Logger.displayError("Error while handling config in trip-confirm-services.js", err)); - }); - - - return ch; -}); diff --git a/www/js/survey/survey.js b/www/js/survey/survey.js deleted file mode 100644 index cd50e4649..000000000 --- a/www/js/survey/survey.js +++ /dev/null @@ -1,43 +0,0 @@ -'use strict'; - -import angular from 'angular'; - -import MultilabelButtonGroup from './multilabel/MultiLabelButtonGroup'; -import UserInputButton from './enketo/UserInputButton'; - -angular.module('emission.survey', [ - "emission.survey.external.launch", - "emission.survey.multilabel.buttons", - "emission.survey.multilabel.infscrollfilters", - "emission.survey.enketo.add-note-button", - "emission.survey.enketo.trip.infscrollfilters", - MultilabelButtonGroup.module, - UserInputButton.module, - ]) - -.factory("SurveyOptions", function() { - var surveyoptions = {}; - console.log("This is currently a NOP; we load the individual components dynamically"); - surveyoptions.MULTILABEL = { - filter: "MultiLabelInfScrollFilters", - service: "MultiLabelService", - elementTag: "multilabel" - } - surveyoptions.ENKETO = { - filter: "EnketoTripInfScrollFilters", - service: "EnketoTripButtonService", - elementTag: "enketo-trip-button" - } - - return surveyoptions; -}) -.directive("linkedsurvey", function($compile) { - return { - scope: { - elementTag:"@", - timelineEntry: "=", - recomputedelay: "@", - }, - templateUrl: "templates/survey/wrapper.html", - }; -}); diff --git a/www/js/survey/survey.ts b/www/js/survey/survey.ts new file mode 100644 index 000000000..e6692983f --- /dev/null +++ b/www/js/survey/survey.ts @@ -0,0 +1,13 @@ +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/templates/control/main-control.html b/www/templates/control/main-control.html index eb98d3278..665eaf7e3 100644 --- a/www/templates/control/main-control.html +++ b/www/templates/control/main-control.html @@ -1,170 +1,7 @@ - - -
      -
      {{settings.auth.opcode}}
      -
      -
      - -
      -
      {{'control.view-privacy'}}
      -
      -
      -
      -
      {{'control.view-qrc'}}
      -
      -
      -
      -
      - {{'control.reminders-time-of-day' | i18next: {time: settings.notification.prefReminderTime} }} -
      -
      - - -
      -
      -
      -
      {{'control.tracking'}}
      - -
      -
      -
      {{'control.app-status'}}
      -
      -
      -
      -
      {{'control.medium-accuracy'}}
      - -
      -
      -
      {{carbonDatasetString}}
      -
      -
      -
      -
      {{'control.force-sync'}}
      -
      -
      -
      -
      {{'control.share'}}
      -
      -
      + + + -
      -
      {{'control.download-json-dump'}}
      -
      - -
      - -
      - -
      -
      {{'control.upload-log'}}
      -
      -
      - -
      -
      {{'control.email-log'}}
      -
      -
      - -
      -
      {{'control.user-data'}}
      -
      -
      - -
      -
      -
      {{'control.erase-data'}}
      -
      -
      - - - -
      {{entry.key}}
      -
      {{entry.value}}
      -
      -
      -
      - - -
      -
      {{'control.dev-zone'}}
      -
      -
      - - -
      -
      -
      {{'control.refresh'}}
      -
      -
      -
      -
      {{'control.end-trip-sync'}}
      -
      -
      -
      -
      {{'control.check-consent'}}
      -
      -
      -
      -
      {{'control.dummy-notification'}}
      -
      -
      - -
      -
      {{'control.upcoming-notifications'}}
      -
      - - -
      -
      {{'control.invalidate-cached-docs'}}
      -
      -
      -
      -
      {{'control.nuke-all'}}
      -
      -
      -
      -
      {{parseState(settings.collect.state)}}
      -
      -
      -
      -
      {{'control.check-log'}}
      -
      -
      -
      -
      {{'control.check-sensed-data'}}
      -
      -
      - - -
      -
      {{'control.collection'}}
      -
      -
      - - - -
      -
      {{'control.sync'}}
      -
      -
      - - -
      -
      {{'control.app-version'}}
      -
      {{settings.clientAppVer}}
      -
      diff --git a/www/templates/main.html b/www/templates/main.html index cdc66d406..c3de4adcb 100644 --- a/www/templates/main.html +++ b/www/templates/main.html @@ -18,7 +18,7 @@ - +