diff --git a/package.cordovabuild.json b/package.cordovabuild.json index 8124f45c5..20672bad0 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -8,6 +8,7 @@ "url": "git+https://github.com/e-mission/e-mission-phone.git" }, "scripts": { + "setup-native": "./bin/download_settings_controls.js", "build": "npx webpack --config webpack.prod.js && npx cordova build", "build-dev": "npx webpack --config webpack.dev.js && npx cordova build", "build-dev-android": "npx webpack --config webpack.dev.js && npx cordova build android", @@ -26,6 +27,7 @@ "@babel/preset-typescript": "^7.21.4", "@ionic/cli": "6.20.8", "@types/luxon": "^3.3.0", + "@types/react": "^18.2.20", "babel-loader": "^9.1.2", "babel-plugin-angularjs-annotate": "^0.10.0", "babel-plugin-optional-require": "^0.3.1", diff --git a/package.serve.json b/package.serve.json index a342a73de..f16c8bd66 100644 --- a/package.serve.json +++ b/package.serve.json @@ -10,6 +10,7 @@ "scripts": { "setup-serve": "./bin/download_settings_controls.js && ./bin/setup_autodeploy.js", "serve": "webpack --config webpack.dev.js && concurrently -k \"phonegap --verbose serve\" \"webpack --config webpack.dev.js --watch\"", + "serve-prod": "webpack --config webpack.prod.js && concurrently -k \"phonegap --verbose serve\" \"webpack --config webpack.prod.js --watch\"", "serve-only": "phonegap --verbose serve", "test": "npx jest" }, @@ -23,7 +24,9 @@ "@babel/preset-typescript": "^7.21.4", "@ionic/cli": "6.20.8", "@types/luxon": "^3.3.0", + "@types/react": "^18.2.20", "babel-loader": "^9.1.2", + "babel-plugin-angularjs-annotate": "^0.10.0", "babel-plugin-optional-require": "^0.3.1", "concurrently": "^8.0.1", "cordova": "^11.1.0", diff --git a/setup/setup_shared_native.sh b/setup/setup_shared_native.sh index 00c72a375..1ce5c64b3 100644 --- a/setup/setup_shared_native.sh +++ b/setup/setup_shared_native.sh @@ -10,6 +10,8 @@ cp setup/google-services.fake.for_ci.json google-services.json echo "Setting up all npm packages" npm install +npm run setup-native + # By default, node doesn't fail if any of the steps fail. This makes it hard to # use in a CI environment, and leads to people reporting the node error rather # than the underlying error. One solution is to pass in a command line argument to node diff --git a/www/__tests__/diaryHelper.test.ts b/www/__tests__/diaryHelper.test.ts index 429fbd08e..822b19bba 100644 --- a/www/__tests__/diaryHelper.test.ts +++ b/www/__tests__/diaryHelper.test.ts @@ -1,5 +1,4 @@ -import { getFormattedSectionProperties, getFormattedDate, motionTypeOf, isMultiDay, getFormattedDateAbbr, getFormattedTimeRange, getPercentages } from "../js/diary/diaryHelper"; -import { useImperialConfig } from "../js/config/useImperialConfig"; +import { getFormattedDate, isMultiDay, getFormattedDateAbbr, getFormattedTimeRange, getDetectedModes, getBaseModeByKey, modeColors } from "../js/diary/diaryHelper"; it('returns a formatted date', () => { expect(getFormattedDate("2023-09-18T00:00:00-07:00")).toBe("Mon September 18, 2023"); @@ -19,10 +18,10 @@ it('returns a human readable time range', () => { expect(getFormattedTimeRange("", "2023-09-18T00:00:00-09:30")).toBeFalsy(); }); -it("returns a MotionType object", () => { - expect(motionTypeOf("WALKING")).toEqual({ name: "WALKING", icon: "walk", color: '#0068a5' }); - expect(motionTypeOf("MotionTypes.WALKING")).toEqual({ name: "WALKING", icon: "walk", color: '#0068a5' }); - expect(motionTypeOf("I made this type up")).toEqual({ name: "UNKNOWN", icon: "help", color: '#484848'}); +it("returns a Base Mode for a given key", () => { + expect(getBaseModeByKey("WALKING")).toEqual({ name: "WALKING", icon: "walk", color: modeColors.blue }); + expect(getBaseModeByKey("MotionTypes.WALKING")).toEqual({ name: "WALKING", icon: "walk", color: modeColors.blue }); + expect(getBaseModeByKey("I made this type up")).toEqual({ name: "UNKNOWN", icon: "help", color: modeColors.grey }); }); it('returns true/false is multi day', () => { @@ -41,25 +40,24 @@ let myFakeTrip2 = {sections: [ { "sensed_mode_str": "BICYCLING", "distance": 715.3078629361006 } ]}; -let myFakePcts = [ +let myFakeDetectedModes = [ { mode: "BICYCLING", icon: "bike", - color: '#007e46', + color: modeColors.green, pct: 89 }, { mode: "WALKING", icon: "walk", - color: '#0068a5', + color: modeColors.blue, pct: 11 }]; -let myFakePcts2 = [ +let myFakeDetectedModes2 = [ { mode: "BICYCLING", icon: "bike", - color: '#007e46', + color: modeColors.green, pct: 100 }]; -it('returns the percetnages by mode for a trip', () => { - expect(getPercentages(myFakeTrip)).toEqual(myFakePcts); - expect(getPercentages(myFakeTrip2)).toEqual(myFakePcts2); - expect(getPercentages({})).toEqual({}); +it('returns the detected modes, with percentages, for a trip', () => { + expect(getDetectedModes(myFakeTrip)).toEqual(myFakeDetectedModes); + expect(getDetectedModes(myFakeTrip2)).toEqual(myFakeDetectedModes2); + expect(getDetectedModes({})).toEqual([]); // empty trip, no sections, no modes }) - diff --git a/www/css/style.css b/www/css/style.css index 9a6f708bd..5e923f5bd 100644 --- a/www/css/style.css +++ b/www/css/style.css @@ -26,16 +26,12 @@ position: relative; } -.fill-container div[class*='css-'] { +.fill-container > div[class*='css-'] { height: 100%; width: 100%; position: absolute; } -.fill-container div[class*='css-'] > div[class*='css-'] { - position: static; -} - /* Without this, the LabelTab does not fill the entire height of the screen. It has something to do with React Navigation's NavigationContainer. This should not be necessary once the entire app's routing has been diff --git a/www/i18n/en.json b/www/i18n/en.json index 0949d5c5c..758960ade 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -44,7 +44,8 @@ "app-version": "App Version", "reminders-time-of-day": "Time of Day for Reminders ({{time}})", "upcoming-notifications": "Upcoming Notifications", - "dummy-notification" : "Dummy Notification in 5 Seconds" + "dummy-notification" : "Dummy Notification in 5 Seconds", + "log-out": "Log Out" }, "general-settings":{ @@ -65,9 +66,6 @@ "consent-found": "Consent found!", "consented-to": "Consented to protocol {{protocol_id}}, {{approval_date}}", "consented-ok": "OK", - "share-message": "Join me in making transportation greener and healthier \nDownload the emission app:", - "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!" }, @@ -130,6 +128,10 @@ "select-mode-scroll": "Mode (👇 for more)", "select-replaced-mode-scroll": "Replaces (👇 for more)", "select-purpose-scroll": "Purpose (👇 for more)", + "delete-entry-confirm": "Are you sure you wish to delete this entry?", + "detected": "Detected:", + "labeled-mode": "Labeled Mode", + "detected-modes": "Detected Modes", "today": "Today", "no-more-travel": "No more travel to show", "show-more-travel": "Show More Travel", @@ -224,6 +226,7 @@ "fix": "Fix", "refresh":"Refresh", "overall-description": "This app works in the background to automatically build a travel diary for you. Make sure that all the settings below are green so that the app can work properly!", + "explanation-title": "What are these used for?", "overall-loc-name": "Location", "overall-loc-description": "We use the background location permission to track your location in the background, even when the app is closed. Reading background locations removes the need to turn tracking on and off, making the app easier to use and preventing battery drain.", "locsettings": { @@ -271,7 +274,8 @@ "name": "Unused apps disabled", "description": { "android-disable-lt-12": "On the app settings page, go to 'Permissions' and ensure that the app permissions will not be automatically reset.", - "android-disable-gte-12": "On the app settings page, turn off 'Remove permissions and free up space.'", + "android-disable-12": "On the app settings page, turn off 'Remove permissions and free up space.'", + "android-disable-gte-13": "On the app settings page, turn off 'Pause app activity if unused.'", "ios": "Please allow." } }, @@ -350,6 +354,69 @@ "invalid-subgroup-no-default": "Invalid OPcode {{token}}, no subgroups, expected 'default' subgroup", "unable-download-config": "Unable to download study config", "invalid-opcode-format": "Invalid OPcode format", - "error-loading-config-app-start": "Error loading config on app start" + "error-loading-config-app-start": "Error loading config on app start", + "survey-missing-formpath": "Error while fetching resources in config: survey_info.surveys has a survey without a formPath" + }, + "errors": { + "while-populating-composite": "Error while populating composite trips", + "while-loading-another-week": "Error while loading travel of {{when}} week", + "while-loading-specific-week": "Error while loading travel for the week of {{day}}" + }, + "consent-text": { + "title":"NREL OPENPATH PRIVACY POLICY/TERMS OF USE", + "introduction":{ + "header":"Introduction and Purpose", + "what-is-openpath":"This data is being collected through OpenPATH, an NREL open-sourced platform. The smart phone application, NREL OpenPATH (“App”), combines data from smartphone sensors, semantic user labels and a short demographic survey.", + "what-is-NREL":"NREL is a national laboratory of the U.S. Department of Energy, Office of Energy Efficiency and Renewable Energy, operated by Alliance for Sustainable Energy, LLC under Prime Contract No. DE-AC36-08GO28308. This Privacy Policy applies to the App provided by Alliance for Sustainable Energy, LLC. This App is provided solely for the purposes of collecting travel behavior data for the {{program_or_study}} and for research to inform public policy. None of the data collected by the App will never be sold or used for any commercial purposes, including advertising.", + "if-disagree":"IF YOU DO NOT AGREE WITH THE TERMS OF THIS PRIVACY POLICY, PLEASE DELETE THE APP" + }, + "why":{ + "header":"Why we collect this information" + }, + "what":{ + "header":"What information we collect", + "no-pii":"The App will never ask for any Personally Identifying Information (PII) such as name, email, address, or phone number.", + "phone-sensor":"It collects phone sensor data pertaining to your location (including background location), accelerometer, device-generated activity and mode recognition, App usage time, and battery usage. The App will create a “travel diary” based on your background location data to determine your travel patterns and location history.", + "labeling":"It will also ask you to periodically annotate these sensed trips with semantic labels, such as the trip mode, purpose, and replaced mode.", + "demographics":"It will also request sociodemographic information such as your approximate age, gender, and household type. The sociodemographic factors can be used to understand the influence of lifestyle on travel behavior, as well as generalize the results to a broader population.", + "open-source-data":"For the greatest transparency, the App is based on an open source platform, NREL’s OpenPATH. you can inspect the data that OpenPATH collects in the background at", + "open-source-analysis":"the analysis pipeline at", + "open-source-dashboard":"and the dashboard metrics at", + "on-nrel-site": "For the greatest transparency, the App is based on an open source platform, NREL’s OpenPATH. you can inspect the data that OpenPATH collects in the background, the analysis pipeline, and the dashboard metrics through links on the NREL OpenPATH website." + }, + "opcode":{ + "header":"How we associate information with you", + "not-autogen":"Program administrators will provide you with a 'opcode' that you will use to log in to the system. This long random string will be used for all further communication with the server. If you forget or lose your opcode, you may request it by providing your name and/or email address to the program administrator. Please do not contact NREL staff with opcode retrieval requests since we do not have access to the connection between your name/email and your opcode. The data that NREL automatically collects (phone sensor data, semidemographic data, etc.) will only be associated with your 'opcode'", + "autogen":"You are logging in with a randomly generated 'opcode' that has been generated by the platform. This long random string will used for all further communication with the server. Only you know the opcode that is associated with you. There is no “Forgot password” option, and NREL staff cannot retrieve your opcode, even if you provide your name or email address. This means that, unless you store your opcode in a safe place, you will not have access to your prior data if you switch phones or uninstall and reinstall the App." + }, + "who-sees":{ + "header":"Who gets to see the information", + "public-dash":"Aggregate metrics derived from the travel patterns will be made available on a public dashboard to provide transparency into the impact of the program. These metrics will focus on information summaries such as counts, distances and durations, and will not display individual travel locations or times.", + "individual-info":"Individual labeling rates and trip level information will only be made available to:", + "program-admins":"🧑 Program administrators from {{deployment_partner_name}} to {{raw_data_use}}, and", + "nrel-devs":"💻 NREL OpenPATH developers for debugging", + "TSDC-info":"The data will also be periodically archived in NREL’s Transportation Secure Data Center (TSDC) after a delay of 3 to 6 months. It will then be made available for legitimate research through existing, privacy-preserving TSDC operating procedures. Further information on the procedures is available", + "on-website":" on the website ", + "and-in":"and in", + "this-pub":" this publication ", + "and":"and", + "fact-sheet":" fact sheet", + "on-nrel-site": " through links on the NREL OpenPATH website." + }, + "rights":{ + "header":"Your rights", + "app-required":"You are required to track your travel patterns using the App as a condition of participation in the Program. If you wish to withdraw from the Program, you should contact the program administrator, {{program_admin_contact}} to discuss termination options. If you wish to stay in the program but not use the app, please contact your program administrator to negotiate an alternative data collection procedure before uninstalling the app. If you uninstall the app without approval from the program administrator, you may not have access to the benefits provided by the program.", + "app-not-required":"Participation in the {{program_or_study}} is completely voluntary. You have the right to decline to participate or to withdraw at any point in the Study without providing notice to NREL or the point of contact. If you do not wish to participate in the Study or to discontinue your participation in the Study, please delete the App.", + "destroy-data-pt1":"If you would like to have your data destroyed, please contact K. Shankari ", + "destroy-data-pt2":" requesting deletion. You must include your token in the request for deletion. Because we do not connect your identity with your token, we cannot delete your information without obtaining the token as part of the deletion request. We will then destroy all data associated with that deletion request, both in the online and archived datasets." + }, + "questions":{ + "header":"Questions", + "for-questions":"If you have any questions about the data collection goals and results, please contact the primary point of contact for the study, {{program_admin_contact}}. If you have any technical questions about app operation, please contact NREL’s K. Shankari (k.shankari@nrel.gov)." + }, + "consent":{ + "header":"Consent", + "press-button-to-consent":"Please select the button below to indicate that you have read and agree to this Privacy Policy, consent to the collection of your information, and want to participate in the {{program_or_study}}." + } } } diff --git a/www/js/appTheme.ts b/www/js/appTheme.ts index dfae3464e..7571a564c 100644 --- a/www/js/appTheme.ts +++ b/www/js/appTheme.ts @@ -10,11 +10,14 @@ const AppTheme = { primary: '#0080b9', // lch(50% 50 250) primaryContainer: '#90ceff', // lch(80% 40 250) onPrimaryContainer: '#001e30', // lch(10% 50 250) - secondary: '#f2795c', // lch(65% 60 40) - secondaryContainer: '#ffb39e', // lch(80% 45 40) + secondary: '#c08331', // lch(60% 55 70) + secondaryContainer: '#fcefda', // lch(95% 12 80) + onSecondaryContainer: '#45392e', // lch(25% 10 65) background: '#edf1f6', // lch(95% 3 250) - background of label screen, other screens still have this as CSS .pane surface: '#fafdff', // lch(99% 30 250) surfaceVariant: '#e0f0ff', // lch(94% 50 250) - background of DataTable + surfaceDisabled: '#c7e0f7', // lch(88% 15 250) + onSurfaceDisabled: '#3a4955', // lch(30% 10 250) elevation: { level0: 'transparent', level1: '#fafdff', // lch(99% 30 250) @@ -23,6 +26,8 @@ const AppTheme = { level4: '#e0f0ff', // lch(94% 50 250) level5: '#d6ebff', // lch(92% 50 250) }, + success: '#38872e', // lch(50% 55 135) + danger: '#f23934' // lch(55% 85 35) }, roundness: 5, }; @@ -57,12 +62,15 @@ const flavorOverrides = { }, } }, - draft: { // for draft TripCards; a greyish color scheme + draft: { // for TripCards and LabelDetailsScreen of draft trips; a greyish color scheme colors: { primary: '#616971', // lch(44 6 250) primaryContainer: '#b6bcc2', // lch(76 4 250) + background: '#eef1f4', // lch(95 2 250) + surface: '#eef1f4', // lch(95 2 250) + surfaceDisabled: '#c7cacd', // lch(81 2 250) elevation: { - level1: '#dbddde', // lch(88 1 250) + level1: '#e1e3e4', // lch(90 1 250) level2: '#d2d5d8', // lch(85 2 250) }, } diff --git a/www/js/appstatus/ExplainPermissions.tsx b/www/js/appstatus/ExplainPermissions.tsx new file mode 100644 index 000000000..cb0db4bba --- /dev/null +++ b/www/js/appstatus/ExplainPermissions.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { Modal, ScrollView, useWindowDimensions, View } from "react-native"; +import { Button, Dialog, Text } from 'react-native-paper'; +import { useTranslation } from "react-i18next"; + +const ExplainPermissions = ({ explanationList, visible, setVisible }) => { + const { t } = useTranslation(); + const { height: windowHeight } = useWindowDimensions(); + + return ( + setVisible(false)} > + setVisible(false)} > + {t('intro.appstatus.explanation-title')} + + + {explanationList?.map((li) => + + + {li.name} + + + {li.desc} + + + )} + + + + + + + + ); +}; + +export default ExplainPermissions; \ No newline at end of file diff --git a/www/js/appstatus/PermissionItem.tsx b/www/js/appstatus/PermissionItem.tsx new file mode 100644 index 000000000..2899943f1 --- /dev/null +++ b/www/js/appstatus/PermissionItem.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { List, Button } from 'react-native-paper'; +import { useTranslation } from "react-i18next"; + +const PermissionItem = ({ check }) => { + const { t } = useTranslation(); + + return ( + } + right={() => } + /> + ); +}; + +export default PermissionItem; \ No newline at end of file diff --git a/www/js/appstatus/permissioncheck.js b/www/js/appstatus/permissioncheck.js index 9abd7f709..84067a701 100644 --- a/www/js/appstatus/permissioncheck.js +++ b/www/js/appstatus/permissioncheck.js @@ -351,8 +351,11 @@ controller("PermissionCheckControl", function($scope, $element, $attrs, return checkOrFix(ignoreBatteryOptCheck, $window.cordova.plugins.BEMDataCollection.isIgnoreBatteryOptimizations, $scope.recomputeBackgroundRestrictionStatus, false); }; - var androidUnusedDescTag = "intro.appstatus.unusedapprestrict.description.android-disable-gte-12"; - if ($scope.osver < 12) { + var androidUnusedDescTag = "intro.appstatus.unusedapprestrict.description.android-disable-gte-13"; + if ($scope.osver == 12) { + androidUnusedDescTag= "intro.appstatus.unusedapprestrict.description.android-disable-12"; + } + else if ($scope.osver < 12) { androidUnusedDescTag= "intro.appstatus.unusedapprestrict.description.android-disable-lt-12"; } let unusedAppsUnrestrictedCheck = { diff --git a/www/js/components/DiaryButton.tsx b/www/js/components/DiaryButton.tsx new file mode 100644 index 000000000..16c716f93 --- /dev/null +++ b/www/js/components/DiaryButton.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { StyleSheet } from 'react-native'; +import { Button, ButtonProps, useTheme } from 'react-native-paper'; +import color from 'color'; +import { Icon } from "./Icon"; + +type Props = ButtonProps & { fillColor?: string, borderColor?: string }; +const DiaryButton = ({ children, fillColor, borderColor, icon, ...rest } : Props) => { + + const { colors } = useTheme(); + const textColor = rest.textColor || (fillColor ? colors.onPrimary : colors.primary); + + return ( + + ); +}; + +const blackAlpha15 = color('black').alpha(0.15).rgb().string(); +const s = StyleSheet.create({ + button: ((borderColor, fillColor, colors) => ({ + width: '100%', + maxWidth: 200, + margin: 'auto', + borderColor: borderColor || (fillColor ? blackAlpha15 : colors.primary), + borderWidth: 1.5, + })) as any, + buttonContent: { + height: 25, + }, + label: { + marginHorizontal: 5, + marginVertical: 0, + fontSize: 13, + fontWeight: '500', + whiteSpace: 'nowrap', + }, + icon: { + marginRight: 4, + verticalAlign: 'middle', + } +}); + +export default DiaryButton; diff --git a/www/js/components/LeafletView.jsx b/www/js/components/LeafletView.jsx index b3ee4184b..eb0c0bb78 100644 --- a/www/js/components/LeafletView.jsx +++ b/www/js/components/LeafletView.jsx @@ -34,9 +34,14 @@ const LeafletView = ({ geojson, opts, ...otherProps }) => { } useEffect(() => { + // if a Leaflet map already exists (because we are re-rendering), remove it before creating a new one + if (leafletMapRef.current) { + leafletMapRef.current.remove(); + mapSet.delete(leafletMapRef.current); + } const map = L.map(mapElRef.current, opts || {}); initMap(map); - }, []); + }, [geojson]); /* If the geojson is different between renders, we need to recreate the map (happens because of FlashList's view recycling on the trip cards: diff --git a/www/js/components/QrCode.jsx b/www/js/components/QrCode.jsx index a73fb780f..0084b334f 100644 --- a/www/js/components/QrCode.jsx +++ b/www/js/components/QrCode.jsx @@ -8,7 +8,7 @@ import { string } from "prop-types"; import QRCode from "react-qr-code"; const QrCode = ({ value }) => { - return ; + return ; }; QrCode.propTypes = { diff --git a/www/js/components/ToggleSwitch.tsx b/www/js/components/ToggleSwitch.tsx new file mode 100644 index 000000000..5fdf1cc46 --- /dev/null +++ b/www/js/components/ToggleSwitch.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { SegmentedButtons, SegmentedButtonsProps, useTheme } from "react-native-paper"; + +const ToggleSwitch = ({ value, buttons, ...rest}: SegmentedButtonsProps) => { + + const { colors } = useTheme(); + + return ( + rest.onValueChange(v as any)} + buttons={buttons.map(o => ({ + value: o.value, + icon: o.icon, + uncheckedColor: colors.onSurfaceDisabled, + showSelectedCheck: true, + style: { + minWidth: 0, + borderTopWidth: rest.density == 'high' ? 0 : 1, + borderBottomWidth: rest.density == 'high' ? 0 : 1, + backgroundColor: value == o.value ? colors.elevation.level1 : colors.surfaceDisabled, + }, + ...o + }))} {...rest} /> + ) +} + +export default ToggleSwitch; diff --git a/www/js/config/dynamic_config.js b/www/js/config/dynamic_config.js index 9daabe2c4..cb84924e7 100644 --- a/www/js/config/dynamic_config.js +++ b/www/js/config/dynamic_config.js @@ -54,7 +54,7 @@ angular.module('emission.config.dynamic', ['emission.plugin.logger', 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'); + throw new Error(i18next.t('config.survey-missing-formpath')); fetchUrlCached(survey.formPath); }); } diff --git a/www/js/control/AlertBar.jsx b/www/js/control/AlertBar.jsx index f640317a4..c56db7ad2 100644 --- a/www/js/control/AlertBar.jsx +++ b/www/js/control/AlertBar.jsx @@ -1,24 +1,36 @@ import React from "react"; -import { Modal, Snackbar} from 'react-native-paper'; +import { Modal } from "react-native"; +import { Snackbar } from 'react-native-paper'; import { useTranslation } from "react-i18next"; +import { SafeAreaView } from "react-native-safe-area-context"; -const AlertBar = ({visible, setVisible, messageKey}) => { +const AlertBar = ({visible, setVisible, messageKey, messageAddition}) => { const { t } = useTranslation(); const onDismissSnackBar = () => setVisible(false); + + let text = ""; + if(messageAddition){ + text = t(messageKey) + messageAddition; + } + else { + text = t(messageKey); + } return ( - setVisible(false)}> - { - onDismissSnackBar() - }, - }}> - {t(messageKey)} - + setVisible(false)} transparent={true}> + + { + onDismissSnackBar() + }, + }}> + {text} + + ); }; diff --git a/www/js/control/AppStatusModal.tsx b/www/js/control/AppStatusModal.tsx new file mode 100644 index 000000000..64c63f720 --- /dev/null +++ b/www/js/control/AppStatusModal.tsx @@ -0,0 +1,453 @@ +//component to view and manage permission settings +import React, { useState, useEffect, useMemo } from "react"; +import { Modal, useWindowDimensions, ScrollView } from "react-native"; +import { Dialog, Button, Text, useTheme } from 'react-native-paper'; +import { useTranslation } from "react-i18next"; +import PermissionItem from "../appstatus/PermissionItem"; +import useAppConfig from "../useAppConfig"; +import useAppStateChange from "../useAppStateChange"; +import ExplainPermissions from "../appstatus/ExplainPermissions"; +import AlertBar from "./AlertBar"; + +const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) => { + const { t } = useTranslation(); + const { colors } = useTheme(); + const { appConfig, loading } = useAppConfig(); + + console.log("settings scope in app status modal", settingsScope); + + const { height: windowHeight } = useWindowDimensions(); + const [osver, setOsver] = useState(0); + const [platform, setPlatform] = useState(""); + + const [error, setError] = useState(""); + const [errorVis, setErrorVis] = useState(false); + + const [explainVis, setExplainVis] = useState(false); + + const [checkList, setCheckList] = useState([]); + const [explanationList, setExplanationList] = useState>([]); + const [haveSetText, setHaveSetText] = useState(false); + + let iconMap = (statusState) => statusState ? "check-circle-outline" : "alpha-x-circle-outline"; + let colorMap = (statusState) => statusState ? colors.success : colors.danger; + + const overallStatus = useMemo(() => { + let status = true; + checkList.forEach((lc) => { + if(!lc.statusState){ + status = false; + } + }) + return status; + }, [checkList]) + + //using this function to update checks rather than mutate + //this cues React to update UI + function updateCheck(newObject) { + var tempList = [...checkList]; //make a copy rather than mutate + tempList.forEach((item, i) => { + if(item.name == newObject.name){ + tempList[i] = newObject; + } + }); + setCheckList(tempList); + } + + async function checkOrFix(checkObj, nativeFn, showError=true) { + console.log("checking object", checkObj.name, checkObj); + let newCheck = checkObj; + return nativeFn() + .then((status) => { + console.log("availability ", status) + newCheck.statusState = true; + updateCheck(newCheck); + console.log("after checking object", checkObj.name, checkList); + return status; + }).catch((error) => { + console.log("Error", error) + if (showError) { + console.log("please fix again"); + setError(error); + setErrorVis(true); + }; + newCheck.statusState = false; + updateCheck(newCheck); + console.log("after checking object", checkObj.name, checkList); + return error; + }); + } + + function setupAndroidLocChecks() { + let fixSettings = function() { + console.log("Fix and refresh location settings"); + return checkOrFix(locSettingsCheck, window['cordova'].plugins.BEMDataCollection.fixLocationSettings, true); + }; + let checkSettings = function() { + console.log("Refresh location settings"); + return checkOrFix(locSettingsCheck, window['cordova'].plugins.BEMDataCollection.isValidLocationSettings, false); + }; + let fixPerms = function() { + console.log("fix and refresh location permissions"); + return checkOrFix(locPermissionsCheck, window['cordova'].plugins.BEMDataCollection.fixLocationPermissions, + true).then((error) => {if(error){locPermissionsCheck.desc = error}}); + }; + let checkPerms = function() { + console.log("fix and refresh location permissions"); + return checkOrFix(locPermissionsCheck, window['cordova'].plugins.BEMDataCollection.isValidLocationPermissions, false); + }; + var androidSettingsDescTag = "intro.appstatus.locsettings.description.android-gte-9"; + if (osver < 9) { + androidSettingsDescTag = "intro.appstatus.locsettings.description.android-lt-9"; + } + var androidPermDescTag = "intro.appstatus.locperms.description.android-gte-12"; + if(osver < 6) { + androidPermDescTag = 'intro.appstatus.locperms.description.android-lt-6'; + } else if (osver < 10) { + androidPermDescTag = "intro.appstatus.locperms.description.android-6-9"; + } else if (osver < 11) { + androidPermDescTag= "intro.appstatus.locperms.description.android-10"; + } else if (osver < 12) { + androidPermDescTag= "intro.appstatus.locperms.description.android-11"; + } + console.log("description tags are "+androidSettingsDescTag+" "+androidPermDescTag); + // location settings + let locSettingsCheck = { + name: t("intro.appstatus.locsettings.name"), + desc: t(androidSettingsDescTag), + statusState: false, + fix: fixSettings, + refresh: checkSettings + } + let locPermissionsCheck = { + name: t("intro.appstatus.locperms.name"), + desc: t(androidPermDescTag), + statusState: false, + fix: fixPerms, + refresh: checkPerms + } + let tempChecks = checkList; + tempChecks.push(locSettingsCheck, locPermissionsCheck); + setCheckList(tempChecks); + } + + function setupIOSLocChecks() { + let fixSettings = function() { + console.log("Fix and refresh location settings"); + return checkOrFix(locSettingsCheck, window['cordova'].plugins.BEMDataCollection.fixLocationSettings, + true); + }; + let checkSettings = function() { + console.log("Refresh location settings"); + return checkOrFix(locSettingsCheck, window['cordova'].plugins.BEMDataCollection.isValidLocationSettings, + false); + }; + let fixPerms = function() { + console.log("fix and refresh location permissions"); + return checkOrFix(locPermissionsCheck, window['cordova'].plugins.BEMDataCollection.fixLocationPermissions, + true).then((error) => {if(error){locPermissionsCheck.desc = error}}); + }; + let checkPerms = function() { + console.log("fix and refresh location permissions"); + return checkOrFix(locPermissionsCheck, window['cordova'].plugins.BEMDataCollection.isValidLocationPermissions, + false); + }; + var iOSSettingsDescTag = "intro.appstatus.locsettings.description.ios"; + var iOSPermDescTag = "intro.appstatus.locperms.description.ios-gte-13"; + if(osver < 13) { + iOSPermDescTag = 'intro.appstatus.locperms.description.ios-lt-13'; + } + console.log("description tags are "+iOSSettingsDescTag+" "+iOSPermDescTag); + + const locSettingsCheck = { + name: t("intro.appstatus.locsettings.name"), + desc: t(iOSSettingsDescTag), + statusState: false, + fix: fixSettings, + refresh: checkSettings + }; + const locPermissionsCheck = { + name: t("intro.appstatus.locperms.name"), + desc: t(iOSPermDescTag), + statusState: false, + fix: fixPerms, + refresh: checkPerms + }; + let tempChecks = checkList; + tempChecks.push(locSettingsCheck, locPermissionsCheck); + setCheckList(tempChecks); + } + + function setupAndroidFitnessChecks() { + if(osver >= 10){ + let fixPerms = function() { + console.log("fix and refresh fitness permissions"); + return checkOrFix(fitnessPermissionsCheck, window['cordova'].plugins.BEMDataCollection.fixFitnessPermissions, + true).then((error) => {if(error){fitnessPermissionsCheck.desc = error}}); + }; + let checkPerms = function() { + console.log("fix and refresh fitness permissions"); + return checkOrFix(fitnessPermissionsCheck, window['cordova'].plugins.BEMDataCollection.isValidFitnessPermissions, + false); + }; + + let fitnessPermissionsCheck = { + name: t("intro.appstatus.fitnessperms.name"), + desc: t("intro.appstatus.fitnessperms.description.android"), + fix: fixPerms, + refresh: checkPerms + } + let tempChecks = checkList; + tempChecks.push(fitnessPermissionsCheck); + setCheckList(tempChecks); + } + } + + function setupIOSFitnessChecks() { + let fixPerms = function() { + console.log("fix and refresh fitness permissions"); + return checkOrFix(fitnessPermissionsCheck, window['cordova'].plugins.BEMDataCollection.fixFitnessPermissions, + true).then((error) => {if(error){fitnessPermissionsCheck.desc = error}}); + }; + let checkPerms = function() { + console.log("fix and refresh fitness permissions"); + return checkOrFix(fitnessPermissionsCheck, window['cordova'].plugins.BEMDataCollection.isValidFitnessPermissions, + false); + }; + + let fitnessPermissionsCheck = { + name: t("intro.appstatus.fitnessperms.name"), + desc: t("intro.appstatus.fitnessperms.description.ios"), + fix: fixPerms, + refresh: checkPerms + } + let tempChecks = checkList; + tempChecks.push(fitnessPermissionsCheck); + setCheckList(tempChecks); + } + + function setupAndroidNotificationChecks() { + let fixPerms = function() { + console.log("fix and refresh notification permissions"); + return checkOrFix(appAndChannelNotificationsCheck, window['cordova'].plugins.BEMDataCollection.fixShowNotifications, + true); + }; + let checkPerms = function() { + console.log("fix and refresh notification permissions"); + return checkOrFix(appAndChannelNotificationsCheck, window['cordova'].plugins.BEMDataCollection.isValidShowNotifications, + false); + }; + let appAndChannelNotificationsCheck = { + name: t("intro.appstatus.notificationperms.app-enabled-name"), + desc: t("intro.appstatus.notificationperms.description.android-enable"), + fix: fixPerms, + refresh: checkPerms + } + let tempChecks = checkList; + tempChecks.push(appAndChannelNotificationsCheck); + setCheckList(tempChecks); + } + + function setupAndroidBackgroundRestrictionChecks() { + let fixPerms = function() { + console.log("fix and refresh backgroundRestriction permissions"); + return checkOrFix(unusedAppsUnrestrictedCheck, window['cordova'].plugins.BEMDataCollection.fixUnusedAppRestrictions, + true); + }; + let checkPerms = function() { + console.log("fix and refresh backgroundRestriction permissions"); + return checkOrFix(unusedAppsUnrestrictedCheck, window['cordova'].plugins.BEMDataCollection.isUnusedAppUnrestricted, + false); + }; + let fixBatteryOpt = function() { + console.log("fix and refresh battery optimization permissions"); + return checkOrFix(ignoreBatteryOptCheck, window['cordova'].plugins.BEMDataCollection.fixIgnoreBatteryOptimizations, + true); + }; + let checkBatteryOpt = function() { + console.log("fix and refresh battery optimization permissions"); + return checkOrFix(ignoreBatteryOptCheck, window['cordova'].plugins.BEMDataCollection.isIgnoreBatteryOptimizations, + false); + }; + var androidUnusedDescTag = "intro.appstatus.unusedapprestrict.description.android-disable-gte-13"; + if (osver == 12) { + androidUnusedDescTag= "intro.appstatus.unusedapprestrict.description.android-disable-12"; + } + else if (osver < 12) { + androidUnusedDescTag= "intro.appstatus.unusedapprestrict.description.android-disable-lt-12"; + } + let unusedAppsUnrestrictedCheck = { + name: t("intro.appstatus.unusedapprestrict.name"), + desc: t(androidUnusedDescTag), + fix: fixPerms, + refresh: checkPerms + } + let ignoreBatteryOptCheck = { + name: t("intro.appstatus.ignorebatteryopt.name"), + desc: t("intro.appstatus.ignorebatteryopt.description.android-disable"), + fix: fixBatteryOpt, + refresh: checkBatteryOpt + } + let tempChecks = checkList; + tempChecks.push(unusedAppsUnrestrictedCheck, ignoreBatteryOptCheck); + setCheckList(tempChecks); + } + + function setupPermissionText() { + let tempExplanations = explanationList; + + let overallFitnessName = t('intro.appstatus.overall-fitness-name-android'); + let locExplanation = t('intro.appstatus.overall-loc-description'); + if(platform == "ios") { + overallFitnessName = t('intro.appstatus.overall-fitness-name-ios'); + if(osver < 13) { + locExplanation = (t("intro.permissions.locationPermExplanation-ios-lt-13")); + } else { + locExplanation = (t("intro.permissions.locationPermExplanation-ios-gte-13")); + } + } + tempExplanations.push({name: t('intro.appstatus.overall-loc-name'), desc: locExplanation}); + tempExplanations.push({name: overallFitnessName, desc: t('intro.appstatus.overall-fitness-description')}); + tempExplanations.push({name: t('intro.appstatus.overall-notification-name'), desc: t('intro.appstatus.overall-notification-description')}); + tempExplanations.push({name: t('intro.appstatus.overall-background-restrictions-name'), desc: t('intro.appstatus.overall-background-restrictions-description')}); + + setExplanationList(tempExplanations); + + //TODO - update samsung handling based on feedback + + console.log("Explanation = "+explanationList); + } + + function createChecklist(){ + if(platform == "android") { + setupAndroidLocChecks(); + setupAndroidFitnessChecks(); + setupAndroidNotificationChecks(); + setupAndroidBackgroundRestrictionChecks(); + } else if (platform == "ios") { + setupIOSLocChecks(); + setupIOSFitnessChecks(); + setupAndroidNotificationChecks(); + } else { + setError("Alert! unknownplatform, no tracking"); + setErrorVis(true); + console.log("Alert! unknownplatform, no tracking"); //need an alert, can use AlertBar? + } + + refreshAllChecks(); + } + + //refreshing checks with the plugins to update the check's statusState + function refreshAllChecks() { + //refresh each check + checkList.forEach((lc) => { + lc.refresh(); + }); + console.log("setting checks are", checkList); + } + + //recomputing checks updates the visual cues of their status + function recomputeAllChecks() { + console.log("recomputing checks", checkList); + checkList.forEach((lc) => { + lc.statusIcon = iconMap(lc.statusState); + lc.statusColor = colorMap(lc.statusState) + }); + } + + //anytime the status changes, may need to show modal + useEffect(() => { + let currentlyOpen = window?.appStatusModalOpened; + if(!currentlyOpen && overallStatus == false && appConfig && haveSetText) { //trying to block early cases from throwing modal + window.appStatusModalOpened = true; + setPermitVis(true); + } + }, [overallStatus]); + + useAppStateChange( function() { + console.log("PERMISSION CHECK: app has resumed, should refresh"); + refreshAllChecks(); + }); + + //refresh when recompute message is broadcast + settingsScope.$on("recomputeAppStatus", function() { + console.log("PERMISSION CHECK: recomputing state"); + refreshAllChecks(); + }); + + //load when ready + useEffect(() => { + if (appConfig && window['device']?.platform) { + setPlatform(window['device'].platform.toLowerCase()); + setOsver(window['device'].version.split(".")[0]); + + if(!haveSetText) + { + //window.appStatusModalOpened = false; + setupPermissionText(); + setHaveSetText(true); + } + else{ + console.log("setting up permissions"); + createChecklist(); + } + + } + }, [appConfig]); + + useEffect (() => { + if(!permitVis) { + window.appStatusModalOpened = false; + } + }, [permitVis]); + + //anytime the checks change (mostly when refreshed), recompute the visual pieces + useEffect(() => { + console.log("checklist changed, updating", checkList); + recomputeAllChecks(); + }, [checkList]) + + return ( + <> + setPermitVis(false)} transparent={true}> + setPermitVis(false)} + style={dialogStyle}> + {t('consent.permissions')} + + + {t('intro.appstatus.overall-description')} + + + {checkList?.map((lc) => + + + )} + + + + + + + + + + + + ) +} + +export default AppStatusModal; \ No newline at end of file diff --git a/www/js/control/DataDatePicker.tsx b/www/js/control/DataDatePicker.tsx new file mode 100644 index 000000000..83e0986b2 --- /dev/null +++ b/www/js/control/DataDatePicker.tsx @@ -0,0 +1,46 @@ +// this date picker element is set up to handle the "download data from day" in ProfileSettings +// it relies on an angular service (Control Helper) but when we migrate that we might want to download a range instead of single + +import React from "react"; +import { DatePickerModal } from 'react-native-paper-dates'; +import { useTranslation } from "react-i18next"; +import { getAngularService } from "../angular-react-helper"; + +const DataDatePicker = ({date, setDate, open, setOpen, minDate}) => { + const { t, i18n } = useTranslation(); //able to pull lang from this + const ControlHelper = getAngularService("ControlHelper"); + + const onDismiss = React.useCallback(() => { + setOpen(false); + }, [setOpen]); + + const onConfirm = React.useCallback( + (params) => { + setOpen(false); + setDate(params.date); + ControlHelper.getMyData(params.date); + }, + [setOpen, setDate] + ); + + const maxDate = new Date(); + + return ( + <> + + + ); +} + +export default DataDatePicker; \ No newline at end of file diff --git a/www/js/control/PopOpCode.jsx b/www/js/control/PopOpCode.jsx index 23368459d..8325b98c7 100644 --- a/www/js/control/PopOpCode.jsx +++ b/www/js/control/PopOpCode.jsx @@ -1,37 +1,56 @@ -import React from "react"; +import React, { useState } from "react"; import { Modal, StyleSheet } from 'react-native'; import { Button, Text, IconButton, Dialog, useTheme } from 'react-native-paper'; import { useTranslation } from "react-i18next"; import QrCode from "../components/QrCode"; +import AlertBar from "./AlertBar"; -const PopOpCode = ({visibilityValue, tokenURL, action, setVis}) => { +const PopOpCode = ({visibilityValue, tokenURL, action, setVis, dialogStyle}) => { const { t } = useTranslation(); const { colors } = useTheme(); + + const opcodeList = tokenURL.split("="); + const opcode = opcodeList[opcodeList.length - 1]; + const [copyAlertVis, setCopyAlertVis] = useState(false); + + const copyText = function(textToCopy){ + navigator.clipboard.writeText(textToCopy).then(() => { + setCopyAlertvis(true); + }) + } + + let copyButton; + if (window.cordova.platformId == "ios"){ + copyButton = {copyText(opcode); setCopyAlertVis(true)}} style={styles.button}/> + } + return ( - setVis(false)} - transparent={true}> - setVis(false)} - style={styles.dialog(colors.elevation.level3)}> - {t("general-settings.qrcode")} - - - {t("general-settings.qrcode-share-title")} - action()} style={styles.button}/> - - - - - - + <> + setVis(false)} + transparent={true}> + setVis(false)} + style={dialogStyle}> + {t("general-settings.qrcode")} + + {t("general-settings.qrcode-share-title")} + + {opcode} + + + action()} style={styles.button}/> + {copyButton} + + + + + + + ) } const styles = StyleSheet.create({ - dialog: (surfaceColor) => ({ - backgroundColor: surfaceColor, - margin: 1, - }), title: { alignItems: 'center', @@ -40,9 +59,19 @@ const styles = StyleSheet.create({ content: { alignItems: 'center', justifyContent: 'center', + margin: 5 }, button: { margin: 'auto', + }, + opcode: { + fontFamily: "monospace", + wordBreak: "break-word", + marginTop: 5 + }, + text : { + fontWeight: 'bold', + marginBottom: 5 } }); diff --git a/www/js/control/PrivacyPolicyModal.tsx b/www/js/control/PrivacyPolicyModal.tsx new file mode 100644 index 000000000..7451581a6 --- /dev/null +++ b/www/js/control/PrivacyPolicyModal.tsx @@ -0,0 +1,195 @@ +import React, { useMemo } from "react"; +import { Modal, useWindowDimensions, ScrollView, Linking, StyleSheet, Text } from "react-native"; +import { Dialog, Button, useTheme } from 'react-native-paper'; +import { useTranslation } from "react-i18next"; +import useAppConfig from "../useAppConfig"; +import i18next from "i18next"; + +const PrivacyPolicyModal = ({ privacyVis, setPrivacyVis, dialogStyle }) => { + const { t } = useTranslation(); + const { height: windowHeight } = useWindowDimensions(); + const { colors } = useTheme(); + const { appConfig, loading } = useAppConfig(); + + const getTemplateText = function(configObject) { + if (configObject && (configObject.name)) { + return configObject.intro.translated_text[i18next.language]; + } + } + + let opCodeText; + if(appConfig?.opcode?.autogen) { + opCodeText = {t('consent-text.opcode.autogen')}; + + } else { + opCodeText = {t('consent-text.opcode.not-autogen')}; + } + + let yourRightsText; + if(appConfig?.intro?.app_required) { + yourRightsText = {t('consent-text.rights.app-required', {program_admin_contact: appConfig?.intro?.program_admin_contact})}; + + } else { + yourRightsText = {t('consent-text.rights.app-not-required', {program_or_study: appConfig?.intro?.program_or_study})}; + } + + const templateText = useMemo(() => getTemplateText(appConfig), [appConfig]); + + return ( + <> + setPrivacyVis(false)} transparent={true}> + setPrivacyVis(false)} + style={dialogStyle}> + {t('consent-text.title')} + + + {t('consent-text.introduction.header')} + {templateText?.short_textual_description} + {'\n'} + {t('consent-text.introduction.what-is-openpath')} + {'\n'} + {t('consent-text.introduction.what-is-NREL', {program_or_study: appConfig?.intro?.program_or_study})} + {'\n'} + {t('consent-text.introduction.if-disagree')} + {'\n'} + + {t('consent-text.why.header')} + {templateText?.why_we_collect} + {'\n'} + + {t('consent-text.what.header')} + {t('consent-text.what.no-pii')} + {'\n'} + {t('consent-text.what.phone-sensor')} + {'\n'} + {t('consent-text.what.labeling')} + {'\n'} + {t('consent-text.what.demographics')} + {'\n'} + {t('consent-text.what.on-nrel-site')} + {/* Linking is broken, look into enabling after migration + + {t('consent-text.what.open-source-data')} + { + Linking.openURL('https://github.com/e-mission/e-mission-data-collection.git'); + }}> + {' '}https://github.com/e-mission/e-mission-data-collection.git{' '} + + {t('consent-text.what.open-source-analysis')} + { + Linking.openURL('https://github.com/e-mission/e-mission-server.git'); + }}> + {' '}https://github.com/e-mission/e-mission-server.git{' '} + + {t('consent-text.what.open-source-dashboard')} + { + Linking.openURL('https://github.com/e-mission/em-public-dashboard.git'); + }}> + {' '}https://github.com/e-mission/em-public-dashboard.git{' '} + + */} + {'\n'} + + {t('consent-text.opcode.header')} + {opCodeText} + {'\n'} + + {t('consent-text.who-sees.header')} + {t('consent-text.who-sees.public-dash')} + {'\n'} + {t('consent-text.who-sees.individual-info')} + {'\n'} + {t('consent-text.who-sees.program-admins', { + deployment_partner_name: appConfig?.intro?.deployment_partner_name, + raw_data_use: templateText?.raw_data_use})} + {t('consent-text.who-sees.nrel-devs')} + {'\n'} + {t('consent-text.who-sees.TSDC-info')} + {/* Linking is broken, look into enabling after migration + { + Linking.openURL('https://nrel.gov/tsdc'); + }}> + {t('consent-text.who-sees.on-website')} + + {t('consent-text.who-sees.and-in')} + { + Linking.openURL('https://www.sciencedirect.com/science/article/pii/S2352146515002999'); + }}> + {t('consent-text.who-sees.this-pub')} + + {t('consent-text.who-sees.and')} + { + Linking.openURL('https://www.nrel.gov/docs/fy18osti/70723.pdf'); + }}> + {t('consent-text.who-sees.fact-sheet')} + */} + {t('consent-text.who-sees.on-nrel-site')} + + {'\n'} + + {t('consent-text.rights.header')} + {yourRightsText} + {'\n'} + {t('consent-text.rights.destroy-data-pt1')} + {/* Linking is broken, look into enabling after migration + { + Linking.openURL("mailto:k.shankari@nrel.gov"); + }}> + k.shankari@nrel.gov + */} + (k.shankari@nrel.gov) + {t('consent-text.rights.destroy-data-pt2')} + + {'\n'} + + {t('consent-text.questions.header')} + {t('consent-text.questions.for-questions', {program_admin_contact: appConfig?.intro?.program_admin_contact})} + {'\n'} + + {t('consent-text.consent.header')} + {t('consent-text.consent.press-button-to-consent', {program_or_study: appConfig?.intro?.program_or_study})} + + + + + + + + + + + ) +} + +const styles = StyleSheet.create({ + hyperlinkStyle: (linkColor) => ({ + color: linkColor + }), + text: { + fontSize: 14 + }, + header: { + fontWeight: "bold", + fontSize: 18 + } + }); + +export default PrivacyPolicyModal; \ No newline at end of file diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index 7249e93e0..e3dab82e3 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from "react"; -import { Modal, StyleSheet } from "react-native"; -import { Dialog, Button, useTheme } from "react-native-paper"; +import { Modal, StyleSheet, ScrollView } from "react-native"; +import { Dialog, Button, useTheme, Text, Appbar, IconButton } from "react-native-paper"; import { angularize, getAngularService } from "../angular-react-helper"; import { useTranslation } from "react-i18next"; import ExpansionSection from "./ExpandMenu"; @@ -10,6 +10,10 @@ import DemographicsSettingRow from "./DemographicsSettingRow"; import PopOpCode from "./PopOpCode"; import ReminderTime from "./ReminderTime" import useAppConfig from "../useAppConfig"; +import AlertBar from "./AlertBar"; +import DataDatePicker from "./DataDatePicker"; +import AppStatusModal from "./AppStatusModal"; +import PrivacyPolicyModal from "./PrivacyPolicyModal"; let controlUpdateCompleteListenerRegistered = false; @@ -21,14 +25,12 @@ const ProfileSettings = () => { 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(); + const mainControlEl = document.getElementById('main-control'); + const settingsScope = angular.element(mainControlEl.querySelector('profile-settings')).scope(); + console.log("settings scope", settingsScope); + // 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; + const { showLog, showSensed } = settingsScope; //angular services needed const CarbonDatasetHelper = getAngularService('CarbonDatasetHelper'); @@ -36,9 +38,12 @@ const ProfileSettings = () => { const EmailHelper = getAngularService('EmailHelper'); const ControlCollectionHelper = getAngularService('ControlCollectionHelper'); const ControlSyncHelper = getAngularService('ControlSyncHelper'); - const CalorieCal = getAngularService('CalorieCal'); const KVStore = getAngularService('KVStore'); const NotificationScheduler = getAngularService('NotificationScheduler'); + const ControlHelper = getAngularService('ControlHelper'); + const ClientStats = getAngularService('ClientStats'); + const StartPrefs = getAngularService('StartPrefs'); + const DynamicConfig = getAngularService('DynamicConfig'); if (!controlUpdateCompleteListenerRegistered) { settingsScope.$on('control.update.complete', function() { @@ -58,8 +63,28 @@ const ProfileSettings = () => { const [nukeSetVis, setNukeVis] = useState(false); const [carbonDataVis, setCarbonDataVis] = useState(false); const [forceStateVis, setForceStateVis] = useState(false); + const [permitVis, setPermitVis] = useState(false); + const [logoutVis, setLogoutVis] = useState(false); + const [dataPendingVis, setDataPendingVis] = useState(false); + const [dataPushedVis, setDataPushedVis] = useState(false); + const [invalidateSuccessVis, setInvalidateSuccessVis] = useState(false); + const [noConsentVis, setNoConsentVis] = useState(false); + const [noConsentMessageVis, setNoConsentMessageVis] = useState(false); + const [consentVis, setConsentVis] = useState(false); + const [dateDumpVis, setDateDumpVis] = useState(false); + const [privacyVis, setPrivacyVis] = useState(false); + const [collectSettings, setCollectSettings] = useState({}); const [notificationSettings, setNotificationSettings] = useState({}); + const [authSettings, setAuthSettings] = useState({}); + const [syncSettings, setSyncSettings] = useState({}); + const [cacheResult, setCacheResult] = useState(""); + const [connectSettings, setConnectSettings] = useState({}); + const [appVersion, setAppVersion] = useState(""); + const [uiConfig, setUiConfig] = useState({}); + const [consentDoc, setConsentDoc] = useState({}); + const [dumpDate, setDumpDate] = useState(new Date()); + const [toggleTime, setToggleTime] = useState(new Date()); let carbonDatasetString = t('general-settings.carbon-dataset') + ": " + CarbonDatasetHelper.getCurrentCarbonDatasetCode(); const carbonOptions = CarbonDatasetHelper.getCarbonDatasetOptions(); @@ -71,12 +96,55 @@ const ProfileSettings = () => { {text: 'Remote push', transition: "RECEIVED_SILENT_PUSH"}] useEffect(() => { - if (appConfig) { - refreshCollectSettings(); - refreshNotificationSettings(); + //added appConfig.name needed to be defined because appConfig was defined but empty + if (appConfig && (appConfig.name)) { + whenReady(appConfig); } }, [appConfig]); + const refreshScreen = function() { + refreshCollectSettings(); + refreshNotificationSettings(); + getOPCode(); + getSyncSettings(); + getConnectURL(); + setAppVersion(ClientStats.getAppVersion()); + } + + //previously not loaded on regular refresh, this ensures it stays caught up + useEffect(() => { + refreshNotificationSettings(); + }, [uiConfig]) + + const whenReady = function(newAppConfig){ + var tempUiConfig = newAppConfig; + + // backwards compat hack to fill in the raw_data_use for programs that don't have it + const default_raw_data_use = { + "en": `to monitor the ${tempUiConfig.intro.program_or_study}, send personalized surveys or provide recommendations to participants`, + "es": `para monitorear el ${tempUiConfig.intro.program_or_study}, enviar encuestas personalizadas o proporcionar recomendaciones a los participantes` + } + Object.entries(tempUiConfig.intro.translated_text).forEach(([lang, val]) => { + val.raw_data_use = val.raw_data_use || default_raw_data_use[lang]; + }); + + // Backwards compat hack to fill in the `app_required` based on the + // old-style "program_or_study" + // remove this at the end of 2023 when all programs have been migrated over + if (tempUiConfig.intro.app_required == undefined) { + tempUiConfig.intro.app_required = tempUiConfig?.intro.program_or_study == 'program'; + } + tempUiConfig.opcode = tempUiConfig.opcode || {}; + if (tempUiConfig.opcode.autogen == undefined) { + tempUiConfig.opcode.autogen = tempUiConfig?.intro.program_or_study == 'study'; + } + + // setTemplateText(tempUiConfig.intro.translated_text); + // console.log("translated text is??", templateText); + setUiConfig(tempUiConfig); + refreshScreen(); + } + async function refreshCollectSettings() { console.debug('about to refreshCollectSettings, collectSettings = ', collectSettings); const newCollectSettings = {}; @@ -90,11 +158,7 @@ const ProfileSettings = () => { 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(); + const isLowAccuracy = await ControlCollectionHelper.isMediumAccuracy(); if (typeof isLowAccuracy != 'undefined') { newCollectSettings.lowAccuracy = isLowAccuracy; } @@ -106,7 +170,7 @@ const ProfileSettings = () => { console.debug('about to refreshNotificationSettings, notificationSettings = ', notificationSettings); const newNotificationSettings ={}; - if (ui_config?.reminderSchemes) { + if (uiConfig?.reminderSchemes) { const prefs = await NotificationScheduler.getReminderPrefs(); const m = moment(prefs.reminder_time_of_day, 'HH:mm'); newNotificationSettings.prefReminderTimeVal = m.toDate(); @@ -120,6 +184,38 @@ const ProfileSettings = () => { console.log("notification settings before and after", notificationSettings, newNotificationSettings); setNotificationSettings(newNotificationSettings); } + + async function getSyncSettings() { + console.log("getting sync settings"); + var newSyncSettings = {}; + ControlSyncHelper.getSyncSettings().then(function(showConfig) { + newSyncSettings.show_config = showConfig; + setSyncSettings(newSyncSettings); + console.log("sync settings are ", syncSettings); + }); + }; + + async function getConnectURL() { + ControlHelper.getSettings().then(function(response) { + var newConnectSettings ={} + newConnectSettings.url = response.connectUrl; + console.log(response); + setConnectSettings(newConnectSettings); + }, function(error) { + Logger.displayError("While getting connect url", error); + }); + } + + async function getOPCode() { + const newAuthSettings = {}; + const opcode = await ControlHelper.getOPCode(); + if(opcode == null){ + newAuthSettings.opcode = "Not logged in"; + } else { + newAuthSettings.opcode = opcode; + } + setAuthSettings(newAuthSettings); + }; //methods that control the settings const uploadLog = function () { @@ -142,6 +238,20 @@ const ProfileSettings = () => { } } + function dummyNotification() { + cordova.plugins.notification.local.addActions('dummy-actions', [ + { id: 'action', title: 'Yes' }, + { id: 'cancel', title: 'No' } + ]); + cordova.plugins.notification.local.schedule({ + id: new Date().getTime(), + title: 'Dummy Title', + text: 'Dummy text', + actions: 'dummy-actions', + trigger: {at: new Date(new Date().getTime() + 5000)}, + }); + } + async function userStartStopTracking() { const transitionToForce = collectSettings.trackingOn ? 'STOP_TRACKING' : 'START_TRACKING'; ControlCollectionHelper.forceTransition(transitionToForce); @@ -150,54 +260,206 @@ const ProfileSettings = () => { So we don't need to call refreshCollectSettings here. */ } - const toggleLowAccuracy = function() { + + const safeToggle = function() { + if(toggleTime){ + const prevTime = toggleTime.getTime(); + const currTime = new Date().getTime(); + if(prevTime + 2000 < currTime ){ + toggleLowAccuracy(); + setToggleTime(new Date()); + } + } + else { + toggleLowAccuracy(); + setToggleTime(new Date()); + } + } + + async function toggleLowAccuracy() { 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); - }); + /*code adapted from demo of react-qr-code*/ + const svg = document.querySelector(".qr-code"); + const svgData = new XMLSerializer().serializeToString(svg); + const img = new Image(); + + img.onload = () => { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + canvas.width = img.width; + canvas.height = img.height; + ctx.drawImage(img, 0, 0); + const pngFile = canvas.toDataURL("image/png"); + + var prepopulateQRMessage = {}; + prepopulateQRMessage.files = [pngFile]; + prepopulateQRMessage.url = authSettings.opcode; + prepopulateQRMessage.message = authSettings.opcode; //text saved to files with image! + + window.plugins.socialsharing.shareWithOptions(prepopulateQRMessage, function(result) { + console.log("Share completed? " + result.completed); // On Android apps mostly return false even while it's true + console.log("Shared to app: " + result.app); // On Android result.app is currently empty. On iOS it's empty when sharing is cancelled (result.completed=false) + }, function(msg) { + console.log("Sharing failed with message: " + msg); + }); + } + img.src = `data:image/svg+xml;base64,${btoa(svgData)}`; } const viewQRCode = function(e) { setOpCodeVis(true); } + + const clearNotifications = function() { + window.cordova.plugins.notification.local.clearAll(); + } - var prepopulateMessage = { - message: t('general-settings.share-message'), - subject: t('general-settings.share-subject'), - url: t('general-settings.share-url') + //Platform.OS returns "web" now, but could be used once it's fully a Native app + //for now, use window.cordova.platformId + + // helper functions for endForceSync + const getStartTransitionKey = function() { + if(window.cordova.platformId == 'android') { + return "local.transition.exited_geofence"; + } + else if(window.cordova.platformId == 'ios') { + return "T_EXITED_GEOFENCE"; + } } - 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); + const getEndTransitionKey = function() { + if(window.cordova.platformId == 'android') { + return "local.transition.stopped_moving"; + } + else if(window.cordova.platformId == 'ios') { + return "T_TRIP_ENDED"; + } + } + + const getOngoingTransitionState = function() { + if(window.cordova.platformId == 'android') { + return "local.state.ongoing_trip"; + } + else if(window.cordova.platformId == 'ios') { + return "STATE_ONGOING_TRIP"; + } + } + + async function getTransition(transKey) { + var entry_data = {}; + const curr_state = await ControlCollectionHelper.getState(); + entry_data.curr_state = curr_state; + if (transKey == getEndTransitionKey()) { + entry_data.curr_state = getOngoingTransitionState(); + } + entry_data.transition = transKey; + entry_data.ts = moment().unix(); + return entry_data; + } + + const parseState = function(state) { + console.log("state in parse state is", state); + if (state) { + console.log("state in parse state exists", window.cordova.platformId); + if(window.cordova.platformId == 'android') { + console.log("ANDROID state in parse state is", state.substring(12)); + return state.substring(12); + } + else if(window.cordova.platformId == 'ios') { + console.log("IOS state in parse state is", state.substring(6)); + return state.substring(6); + } + } + } + + async function endForceSync() { + /* First, quickly start and end the trip. Let's listen to the promise + * result for start so that we ensure ordering */ + var sensorKey = "statemachine/transition"; + return getTransition(getStartTransitionKey()).then(function(entry_data) { + return window.cordova.plugins.BEMUserCache.putMessage(sensorKey, entry_data); + }).then(function() { + return getTransition(getEndTransitionKey()).then(function(entry_data) { + return window.cordova.plugins.BEMUserCache.putMessage(sensorKey, entry_data); + }) + }).then(forceSync); + } + + //showing up in an odd space on the screen!! + async function forceSync() { + ClientStats.addEvent(ClientStats.getStatKeys().BUTTON_FORCE_SYNC).then( + function() { + console.log("Added "+ClientStats.getStatKeys().BUTTON_FORCE_SYNC+" event"); }); + ControlSyncHelper.forceSync().then(function() { + /* + * Change to sensorKey to "background/location" after fixing issues + * with getLastSensorData and getLastMessages in the usercache + * See https://github.com/e-mission/e-mission-phone/issues/279 for details + */ + var sensorKey = "statemachine/transition"; + return window.cordova.plugins.BEMUserCache.getAllMessages(sensorKey, true); + }).then(function(sensorDataList) { + Logger.log("sensorDataList = "+JSON.stringify(sensorDataList)); + // If everything has been pushed, we should + // only have one entry for the battery, which is the one that was + // inserted on the last successful push. + var isTripEnd = function(entry) { + if (entry.metadata.key == getEndTransitionKey()) { + return true; + } else { + return false; + } + }; + var syncLaunchedCalls = sensorDataList.filter(isTripEnd); + var syncPending = (syncLaunchedCalls.length > 0); + Logger.log("sensorDataList.length = "+sensorDataList.length+ + ", syncLaunchedCalls.length = "+syncLaunchedCalls.length+ + ", syncPending? = "+syncPending); + return syncPending; + }).then(function(syncPending) { + Logger.log("sync launched = "+syncPending); + if (syncPending) { + Logger.log("data is pending, showing confirm dialog"); + setDataPendingVis(true); //consent handling in modal + } else { + setDataPushedVis(true); + } + }).catch(function(error) { + Logger.displayError("Error while forcing sync", error); + }); + }; + + async function invalidateCache() { + window.cordova.plugins.BEMUserCache.invalidateAllCache().then(function(result) { + console.log("invalidate result", result); + setCacheResult(result); + setInvalidateSuccessVis(true); + }, function(error) { + Logger.displayError("while invalidating cache, error->", error); + }); } - //conditional creation of setting sections - let userDataSection; - if(userDataSaved()) - { - userDataSection = - - - ; + //in ProfileSettings in DevZone (above two functions are helpers) + async function checkConsent() { + StartPrefs.getConsentDocument().then(function(resultDoc){ + setConsentDoc(resultDoc); + if (resultDoc == null) { + setNoConsentVis(true); + } else { + setConsentVis(true); + } + }, function(error) { + Logger.displayError("Error reading consent document from cache", error) + }); } + //conditional creation of setting sections + let logUploadSection; console.debug("appConfg: support_upload:", appConfig?.profile_controls?.support_upload); if (appConfig?.profile_controls?.support_upload) { @@ -215,42 +477,46 @@ const ProfileSettings = () => { return ( <> - - - - - {timePicker} - - - - setCarbonDataVis(true)}> - - - - {logUploadSection} - - - {userDataSection} + + + {t('control.log-out')} + setLogoutVis(true)}> + + + + + + setPrivacyVis(true)}> + {timePicker} + + setPermitVis(true)}> + + setCarbonDataVis(true)}> + + setDateDumpVis(true)}> + {logUploadSection} + - - - - - - {notifSchedule} - - setNukeVis(true)}> - setForceStateVis(true)}> - - - - - - - console.log("")} desc={settings?.clientAppVer}> - - - {/* menu for "nuke data" */} + + + + + + {notifSchedule} + + setNukeVis(true)}> + setForceStateVis(true)}> + + + + + + + + console.log("")} desc={appVersion}> + + + {/* menu for "nuke data" */} setNukeVis(false)} transparent={true}> { - {/* menu for "set carbon dataset - only somewhat working" */} + {/* menu for "set carbon dataset - only somewhat working" */} setCarbonDataVis(false)} transparent={true}> { )} - + @@ -332,14 +599,112 @@ const ProfileSettings = () => { {/* opcode viewing popup */} - + + + {/* {view permissions} */} + + + {/* {view privacy} */} + + + {/* logout menu */} + setLogoutVis(false)} transparent={true}> + setLogoutVis(false)} + style={styles.dialog(colors.elevation.level3)}> + {t('general-settings.are-you-sure')} + + {t('general-settings.log-out-warning')} + + + + + + + + + {/* dataPending */} + setDataPendingVis(false)} transparent={true}> + setDataPendingVis(false)} + style={styles.dialog(colors.elevation.level3)}> + {t('data pending for push')} + + + + + + + + {/* handle no consent */} + setNoConsentVis(false)} transparent={true}> + setNoConsentVis(false)} + style={styles.dialog(colors.elevation.level3)}> + {t('general-settings.consent-not-found')} + + + + + + + + {/* handle consent */} + setConsentVis(false)} transparent={true}> + setConsentVis(false)} + style={styles.dialog(colors.elevation.level3)}> + {t('general-settings.consented-to', {protocol_id: consentDoc.protocol_id, approval_date: consentDoc.approval_date})} + + + + + + + + + + + + + ); }; const styles = StyleSheet.create({ dialog: (surfaceColor) => ({ backgroundColor: surfaceColor, - margin: 1, + margin: 5, + marginLeft: 25, + marginRight: 25 }), monoDesc: { fontSize: 12, diff --git a/www/js/control/general-settings.js b/www/js/control/general-settings.js index 023aca98d..5225af1c6 100644 --- a/www/js/control/general-settings.js +++ b/www/js/control/general-settings.js @@ -9,546 +9,36 @@ angular.module('emission.main.control',['emission.services', 'emission.main.control.sync', 'emission.splash.localnotify', 'emission.splash.notifscheduler', - 'ionic-datepicker', - 'ionic-toast', - 'ionic-datepicker.provider', 'emission.splash.startprefs', 'emission.main.metrics.factory', 'emission.stats.clientstats', 'emission.plugin.kvstore', - 'emission.survey.enketo.demographics', 'emission.plugin.logger', 'emission.config.dynamic', ProfileSettings.module]) -.controller('ControlCtrl', function($scope, $window, - $ionicScrollDelegate, $ionicPlatform, - $state, $ionicPopup, $ionicActionSheet, $ionicPopover, - $ionicModal, $stateParams, - $rootScope, KVStore, ionicDatePicker, ionicToast, +.controller('ControlCtrl', function($scope, $ionicPlatform, + $state, $ionicPopover, i18nUtils, + $ionicModal, $stateParams, Logger, + KVStore, CalorieCal, ClientStats, StartPrefs, ControlHelper, EmailHelper, UploadHelper, ControlCollectionHelper, ControlSyncHelper, - CarbonDatasetHelper, NotificationScheduler, LocalNotify, - i18nUtils, - CalorieCal, ClientStats, CommHelper, Logger, DynamicConfig) { + CarbonDatasetHelper, NotificationScheduler, DynamicConfig) { console.log("controller ControlCtrl called without params"); - var datepickerObject = { - todayLabel: i18next.t('list-datepicker-today'), //Optional - closeLabel: i18next.t('list-datepicker-close'), //Optional - setLabel: i18next.t('list-datepicker-set'), //Optional - monthsList: moment.monthsShort(), - weeksList: moment.weekdaysMin(), - titleLabel: i18next.t('general-settings.choose-date'), - setButtonType : 'button-positive', //Optional - todayButtonType : 'button-stable', //Optional - closeButtonType : 'button-stable', //Optional - inputDate: moment().subtract(1, 'week').toDate(), //Optional - from: new Date(2015, 1, 1), - to: new Date(), - mondayFirst: true, //Optional - templateType: 'popup', //Optional - showTodayButton: 'true', //Optional - modalHeaderColor: 'bar-positive', //Optional - modalFooterColor: 'bar-positive', //Optional - callback: ControlHelper.getMyData, //Mandatory - dateFormat: 'dd MMM yyyy', //Optional - closeOnSelect: true //Optional - } - - $scope.overallAppStatus = false; - - $ionicModal.fromTemplateUrl('templates/control/app-status-modal.html', { - scope: $scope - }).then(function(modal) { - $scope.appStatusModal = modal; - if ($stateParams.launchAppStatusModal == true) { - $scope.$broadcast("recomputeAppStatus"); - $scope.appStatusModal.show(); - } - }); - - $scope.openDatePicker = function(){ - ionicDatePicker.openDatePicker(datepickerObject); - }; - - //this function used in ProfileSettings to viewPrivacyPolicy - $scope.viewPrivacyPolicy = function($event) { - // button -> list element -> scroll - // const targetEl = $event.currentTarget.parentElement.parentElement; - if ($scope.ppp) { - $scope.ppp.show($event); - } else { - i18nUtils.geti18nFileName("templates/", "intro/consent-text", ".html").then((consentFileName) => { - $scope.consentTextFile = consentFileName; - $ionicPopover.fromTemplateUrl("templates/control/main-consent.html", {scope: $scope}).then((p) => { - $scope.ppp = p; - $scope.ppp.show($event); - }); - }).catch((err) => Logger.displayError("Error while displaying privacy policy", err)); - } - } - - //this function used in ProfileSettings to send DummyNotification - $scope.dummyNotification = () => { - cordova.plugins.notification.local.addActions('dummy-actions', [ - { id: 'action', title: 'Yes' }, - { id: 'cancel', title: 'No' } - ]); - cordova.plugins.notification.local.schedule({ - id: new Date().getTime(), - title: 'Dummy Title', - text: 'Dummy text', - actions: 'dummy-actions', - trigger: {at: new Date(new Date().getTime() + 5000)}, - }); - } - - //called in ProfileSettings on the AppStatus row - $scope.fixAppStatus = function() { - $scope.$broadcast("recomputeAppStatus"); - $scope.appStatusModal.show(); - } - - $scope.appStatusChecked = function() { - // Hardcoded value so we can publish the hacky version today and then debug/fix the - // infinite loop around waiting_for_trip_start -> tracking_error - $window.cordova.plugins.notification.local.clearAll(); - $scope.appStatusModal.hide(); - } - - $scope.userData = [] - $scope.getUserData = function() { - return CalorieCal.get().then(function(userDataFromStorage) { - $scope.rawUserData = userDataFromStorage; - if ($scope.userDataSaved()) { - $scope.userData = [] - var height = userDataFromStorage.height.toString(); - var weight = userDataFromStorage.weight.toString(); - var temp = { - age: userDataFromStorage.age, - height: height + (userDataFromStorage.heightUnit == 1? ' cm' : ' ft'), - weight: weight + (userDataFromStorage.weightUnit == 1? ' kg' : ' lb'), - gender: userDataFromStorage.gender == 1? i18next.t('gender-male') : i18next.t('gender-female') - } - for (var i in temp) { - $scope.userData.push({key: i, val: temp[i]}); //needs to be val for the data table! - } - } - }); - } - - $scope.userDataSaved = function() { - if (angular.isDefined($scope.rawUserData) && $scope.rawUserData != null) { - return $scope.rawUserData.userDataSaved; - } else { - return false; - } - } - $ionicPlatform.ready().then(function() { - DynamicConfig.configReady().then(function(newConfig) { - $scope.ui_config = newConfig; - // backwards compat hack to fill in the raw_data_use for programs that don't have it - const default_raw_data_use = { - "en": `to monitor the ${newConfig.intro.program_or_study}, send personalized surveys or provide recommendations to participants`, - "es": `para monitorear el ${newConfig.intro.program_or_study}, enviar encuestas personalizadas o proporcionar recomendaciones a los participantes` - } - Object.entries(newConfig.intro.translated_text).forEach(([lang, val]) => { - val.raw_data_use = val.raw_data_use || default_raw_data_use[lang]; - }); - // TODO: we should be able to use $translate for this, right? - $scope.template_text = newConfig.intro.translated_text[$scope.lang]; - if (!$scope.template_text) { - $scope.template_text = newConfig.intro.translated_text["en"] - } - // Backwards compat hack to fill in the `app_required` based on the - // old-style "program_or_study" - // remove this at the end of 2023 when all programs have been migrated over - if ($scope.ui_config.intro.app_required == undefined) { - $scope.ui_config.intro.app_required = $scope.ui_config?.intro.program_or_study == 'program'; - } - $scope.ui_config.opcode = $scope.ui_config.opcode || {}; - if ($scope.ui_config.opcode.autogen == undefined) { - $scope.ui_config.opcode.autogen = $scope.ui_config?.intro.program_or_study == 'study'; - } - $scope.refreshScreen(); - }); - }); + //used to have on "afterEnter" that checked for 2 things + //modal launch -> migrated into AppStatusModal w/ use of custom hook! + //stateParams.openTimeOfDayPicker -> functionality lost for now + //to change reminder time if accessing profile by specific android notification flow + //would open the date picker - $scope.getConnectURL = function() { - ControlHelper.getSettings().then(function(response) { - $scope.$apply(function() { - $scope.settings.connect.url = response.connectUrl; - console.log(response); - }); - }, function(error) { - Logger.displayError("While getting connect url", error); - }); - }; - - $scope.getSyncSettings = function() { - ControlSyncHelper.getSyncSettings().then(function(showConfig) { - $scope.$apply(function() { - $scope.settings.sync.show_config = showConfig; - }) - }); - }; - - $scope.getOPCode = function() { - ControlHelper.getOPCode().then(function(opcode) { - console.log("opcode = "+opcode); - $scope.$apply(function() { - if (opcode == null) { - $scope.settings.auth.opcode = "Not logged in"; - } else { - $scope.settings.auth.opcode = opcode; - } - }); - }, function(error) { - Logger.displayError("while getting opcode, ",error); - }); - }; - //in ProfileSettings in DevZone + //TODO create React pages and use React routing $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) { - /* 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); - }); - }; - - var clearUsercache = function() { - $ionicPopup.alert({template: "WATCH OUT! If there is unsynced data, you may lose it. If you want to keep the data, use 'Force Sync' before doing this"}) - .then(function(result) { - if (result) { - window.cordova.plugins.BEMUserCache.clearAll() - .then(function(result) { - $scope.$apply(function() { - $ionicPopup.alert({template: 'success -> '+result}); - }); - }, function(error) { - Logger.displayError("while clearing user cache, error ->", error); - }); - } - }); - } - - //in ProfileSettings in DevZone - $scope.invalidateCache = function() { - window.cordova.plugins.BEMUserCache.invalidateAllCache().then(function(result) { - $scope.$apply(function() { - $ionicPopup.alert({template: 'success -> '+result}); - }); - }, function(error) { - Logger.displayError("while invalidating cache, error->", error); - }); - } - - $scope.$on('$ionicView.afterEnter', function() { - console.log("afterEnter called with stateparams", $stateParams); - $ionicPlatform.ready().then(function() { - $scope.refreshScreen(); - if ($stateParams.launchAppStatusModal == true) { - $scope.$broadcast("recomputeAppStatus"); - $scope.appStatusModal.show(); - $stateParams.launchAppStatusModal = false; - } - if ($stateParams.openTimeOfDayPicker) { - $('input[name=timeOfDay]').focus(); - } - }); - }) - - // Execute action on hidden popover - $scope.$on('control.update.complete', function() { - $scope.refreshScreen(); - }); - - $scope.$on('popover.hidden', function() { - $scope.refreshScreen(); - }); - - //in ProfileSettings in DevZone - $scope.refreshScreen = function() { - console.log("Refreshing screen"); - $scope.settings = {}; - $scope.settings.sync = {}; - $scope.settings.auth = {}; - $scope.settings.connect = {}; - $scope.settings.clientAppVer = ClientStats.getAppVersion(); - $scope.getConnectURL(); - $scope.getSyncSettings(); - $scope.getOPCode(); - $scope.getUserData(); - }; - - //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'), - template: i18next.t('general-settings.log-out-warning'), - cancelText: i18next.t('general-settings.cancel'), - okText: i18next.t('general-settings.confirm') - }).then(function(res) { - if (!res) return; // user cancelled - - // reset the saved config, then trigger a hard refresh - DynamicConfig.resetConfigAndRefresh(); - }); - }; - - var getStartTransitionKey = function() { - if($scope.isAndroid()) { - return "local.transition.exited_geofence"; - } - else if($scope.isIOS()) { - return "T_EXITED_GEOFENCE"; - } - } - - var getEndTransitionKey = function() { - if($scope.isAndroid()) { - return "local.transition.stopped_moving"; - } - else if($scope.isIOS()) { - return "T_TRIP_ENDED"; - } - } - - var getOngoingTransitionState = function() { - if($scope.isAndroid()) { - return "local.state.ongoing_trip"; - } - else if($scope.isIOS()) { - return "STATE_ONGOING_TRIP"; - } - } - - $scope.forceSync = function() { - ClientStats.addEvent(ClientStats.getStatKeys().BUTTON_FORCE_SYNC).then( - function() { - console.log("Added "+ClientStats.getStatKeys().BUTTON_FORCE_SYNC+" event"); - }); - ControlSyncHelper.forceSync().then(function() { - /* - * Change to sensorKey to "background/location" after fixing issues - * with getLastSensorData and getLastMessages in the usercache - * See https://github.com/e-mission/e-mission-phone/issues/279 for details - */ - var sensorKey = "statemachine/transition"; - return window.cordova.plugins.BEMUserCache.getAllMessages(sensorKey, true); - }).then(function(sensorDataList) { - Logger.log("sensorDataList = "+JSON.stringify(sensorDataList)); - // If everything has been pushed, we should - // only have one entry for the battery, which is the one that was - // inserted on the last successful push. - var isTripEnd = function(entry) { - if (entry.metadata.key == getEndTransitionKey()) { - return true; - } else { - return false; - } - }; - var syncLaunchedCalls = sensorDataList.filter(isTripEnd); - var syncPending = (syncLaunchedCalls.length > 0); - Logger.log("sensorDataList.length = "+sensorDataList.length+ - ", syncLaunchedCalls.length = "+syncLaunchedCalls.length+ - ", syncPending? = "+syncPending); - return syncPending; - }).then(function(syncPending) { - Logger.log("sync launched = "+syncPending); - if (syncPending) { - Logger.log("data is pending, showing confirm dialog"); - $ionicPopup.confirm({template: 'data pending for push'}).then(function(res) { - if (res) { - $scope.forceSync(); - } else { - Logger.log("user refused to re-sync"); - } - }); - } else { - $ionicPopup.alert({template: 'all data pushed!'}); - } - }).catch(function(error) { - Logger.displayError("Error while forcing sync", error); - }); - }; - - var getTransition = function(transKey) { - var entry_data = {}; - return $scope.getState().then(function(curr_state) { - entry_data.curr_state = curr_state; - if (transKey == getEndTransitionKey()) { - entry_data.curr_state = getOngoingTransitionState(); - } - entry_data.transition = transKey; - entry_data.ts = moment().unix(); - return entry_data; - }) - } - - //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 */ - var sensorKey = "statemachine/transition"; - return getTransition(getStartTransitionKey()).then(function(entry_data) { - return window.cordova.plugins.BEMUserCache.putMessage(sensorKey, entry_data); - }).then(function() { - return getTransition(getEndTransitionKey()).then(function(entry_data) { - return window.cordova.plugins.BEMUserCache.putMessage(sensorKey, entry_data); - }) - }).then($scope.forceSync); - } - - $scope.isAndroid = function() { - return ionic.Platform.isAndroid(); - } - - $scope.isIOS = function() { - return ionic.Platform.isIOS(); - } - - $ionicPopover.fromTemplateUrl('templates/control/main-sync-settings.html', { - scope: $scope - }).then(function(popover) { - $scope.syncSettingsPopup = popover; - }); - - //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()){ - return state.substring(12); - } else if ($scope.isIOS()) { - return state.substring(6); - } - } - } - // //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')}) - .then(function(res){ - if (res) { - $state.go("root.reconsent"); - } else { - $ionicPopup.alert({ - template: i18next.t('general-settings.no-consent-message')}); - } - }); - } - - var handleConsent = function(resultDoc) { - $scope.consentDoc = resultDoc; - $ionicPopup.confirm({ - template: i18next.t('general-settings.consented-to',{protocol_id: $scope.consentDoc.protocol_id,approval_date: $scope.consentDoc.approval_date}), - scope: $scope, - title: i18next.t('general-settings.consent-found'), - buttons: [ - // {text: "View", - // type: 'button-calm'}, - {text: ""+ i18next.t('general-settings.consented-ok') +"", - type: 'button-positive'} ] - }).finally(function(res) { - $scope.consentDoc = null; - }); - } - - //in ProfileSettings in DevZone (above two functions are helpers) - $scope.checkConsent = function() { - StartPrefs.getConsentDocument().then(function(resultDoc){ - if (resultDoc == null) { - handleNoConsent(resultDoc); - } else { - handleConsent(resultDoc); - } - }, function(error) { - Logger.displayError("Error reading consent document from cache", error) - }); - } - - var prepopulateMessage = { - message: i18next.t('general-settings.share-message'), // not supported on some apps (Facebook, Instagram) - subject: i18next.t('general-settings.share-subject'), // fi. for email - url: i18next.t('general-settings.share-url') - } - - $scope.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); - }); - } - - $scope.shareQR = function() { - /*code adapted from demo of react-qr-code - selector below gets svg element out of angularized QRCode - this will change upon later migration*/ - const svg = document.querySelector("qr-code svg"); - const svgData = new XMLSerializer().serializeToString(svg); - const img = new Image(); - - img.onload = () => { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - canvas.width = img.width; - canvas.height = img.height; - ctx.drawImage(img, 0, 0); - const pngFile = canvas.toDataURL("image/png"); - - var prepopulateQRMessage = {}; - prepopulateQRMessage.files = [pngFile]; - prepopulateQRMessage.url = $scope.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); - }); - } - img.src = `data:image/svg+xml;base64,${btoa(svgData)}`; - } - }); diff --git a/www/js/diary/DiaryButton.tsx b/www/js/diary/DiaryButton.tsx deleted file mode 100644 index 7a095f53e..000000000 --- a/www/js/diary/DiaryButton.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from "react"; -import { StyleSheet } from 'react-native'; -import { Button, ButtonProps, useTheme } from 'react-native-paper'; -import { Icon } from "../components/Icon"; - -type Props = ButtonProps & { fillColor?: string }; -const DiaryButton = ({ children, fillColor, icon, ...rest } : Props) => { - - const { colors } = useTheme(); - const style = fillColor ? { color: colors.onPrimary } - : { borderColor: colors.primary, borderWidth: 1.5 }; - - return ( - - ); -}; - -const s = StyleSheet.create({ - buttonContent: { - height: 25, - }, - label: { - marginHorizontal: 5, - marginVertical: 0, - fontSize: 13, - fontWeight: '500', - whiteSpace: 'nowrap', - }, - icon: { - marginRight: 4, - verticalAlign: 'middle', - } -}); - -export default DiaryButton; diff --git a/www/js/diary/LabelDetailsScreen.tsx b/www/js/diary/LabelDetailsScreen.tsx deleted file mode 100644 index 6af9bbf0d..000000000 --- a/www/js/diary/LabelDetailsScreen.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/* A screen to show details of a trip, including a recap of trip info, a full-size map, - listed sections of the trip, and a graph of speed during the trip. - Navigated to from the main LabelListScreen by clicking a trip card. */ - -import React, { useContext } from "react"; -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 { 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 { t } = useTranslation(); - const { height: windowHeight } = useWindowDimensions(); - const { colors } = useTheme(); - 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}; - - return ( - - - - { navigation.goBack() }} /> - - - - - - - - - - - - {t('diary.distance')} - - - {`${formattedDistance} ${distanceSuffix}`} - - - - - {t('diary.time')} - - - {displayTime} - - - - {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 */} - - - - - - ) -} - -export default LabelScreenDetails; diff --git a/www/js/diary/LabelTab.tsx b/www/js/diary/LabelTab.tsx index 98643f2cc..e90f8c060 100644 --- a/www/js/diary/LabelTab.tsx +++ b/www/js/diary/LabelTab.tsx @@ -11,15 +11,18 @@ import { angularize, getAngularService } from "../angular-react-helper"; import useAppConfig from "../useAppConfig"; import { useTranslation } from "react-i18next"; import { invalidateMaps } from "../components/LeafletView"; -import Bottleneck from "bottleneck"; import moment from "moment"; -import LabelListScreen from "./LabelListScreen"; +import LabelListScreen from "./list/LabelListScreen"; import { createStackNavigator } from "@react-navigation/stack"; -import LabelScreenDetails from "./LabelDetailsScreen"; +import LabelScreenDetails from "./details/LabelDetailsScreen"; import { NavigationContainer } from "@react-navigation/native"; import { compositeTrips2TimelineMap, getAllUnprocessedInputs, getLocalUnprocessedInputs, populateCompositeTrips } from "./timelineHelper"; import { fillLocationNamesOfTrip, resetNominatimLimiter } from "./addressNamesHelper"; import { SurveyOptions } from "../survey/survey"; +import { getLabelOptions } from "../survey/multilabel/confirmHelper"; +import { displayError } from "../plugin/logger"; +import AppStatusModal from "../control/AppStatusModal"; +import { useTheme } from "react-native-paper"; let labelPopulateFactory, labelsResultMap, notesResultMap, showPlaces; const ONE_DAY = 24 * 60 * 60; // seconds @@ -29,8 +32,10 @@ export const LabelTabContext = React.createContext(null); const LabelTab = () => { const { appConfig, loading } = useAppConfig(); const { t } = useTranslation(); + const { colors } = useTheme(); const [surveyOpt, setSurveyOpt] = useState(null); + const [labelOptions, setLabelOptions] = useState(null); const [filterInputs, setFilterInputs] = useState([]); const [pipelineRange, setPipelineRange] = useState(null); const [queriedRange, setQueriedRange] = useState(null); @@ -47,6 +52,8 @@ const LabelTab = () => { const CommHelper = getAngularService('CommHelper'); const enbs = getAngularService('EnketoNotesButtonService'); + const [permissionVis, setPermissionVis] = useState(false); + // initialization, once the appConfig is loaded useEffect(() => { if (loading) return; @@ -54,6 +61,7 @@ const LabelTab = () => { const surveyOpt = SurveyOptions[surveyOptKey]; setSurveyOpt(surveyOpt); showPlaces = appConfig.survey_info?.buttons?.['place-notes']; + getLabelOptions().then((labelOptions) => setLabelOptions(labelOptions)); labelPopulateFactory = getAngularService(surveyOpt.service); const tripSurveyName = appConfig.survey_info?.buttons?.['trip-notes']?.surveyName; const placeSurveyName = appConfig.survey_info?.buttons?.['place-notes']?.surveyName; @@ -129,47 +137,56 @@ const LabelTab = () => { } async function loadAnotherWeek(when: 'past'|'future') { - const reachedPipelineStart = queriedRange?.start_ts && queriedRange.start_ts <= pipelineRange.start_ts; - const reachedPipelineEnd = queriedRange?.end_ts && queriedRange.end_ts >= pipelineRange.end_ts; + try { + const reachedPipelineStart = queriedRange?.start_ts && queriedRange.start_ts <= pipelineRange.start_ts; + const reachedPipelineEnd = queriedRange?.end_ts && queriedRange.end_ts >= pipelineRange.end_ts; - if (!queriedRange) { - // first time loading - if(!isLoading) setIsLoading('replace'); - const nowTs = new Date().getTime() / 1000; - const [ctList, utList] = await fetchTripsInRange(pipelineRange.end_ts - ONE_WEEK, nowTs); - handleFetchedTrips(ctList, utList, 'replace'); - setQueriedRange({start_ts: pipelineRange.end_ts - ONE_WEEK, end_ts: nowTs}); - } else if (when == 'past' && !reachedPipelineStart) { - if(!isLoading) setIsLoading('prepend'); - const fetchStartTs = Math.max(queriedRange.start_ts - ONE_WEEK, pipelineRange.start_ts); - const [ctList, utList] = await fetchTripsInRange(queriedRange.start_ts - ONE_WEEK, queriedRange.start_ts - 1); - handleFetchedTrips(ctList, utList, 'prepend'); - setQueriedRange({start_ts: fetchStartTs, end_ts: queriedRange.end_ts}) - } else if (when == 'future' && !reachedPipelineEnd) { - if(!isLoading) setIsLoading('append'); - const fetchEndTs = Math.min(queriedRange.end_ts + ONE_WEEK, pipelineRange.end_ts); - const [ctList, utList] = await fetchTripsInRange(queriedRange.end_ts + 1, fetchEndTs); - handleFetchedTrips(ctList, utList, 'append'); - setQueriedRange({start_ts: queriedRange.start_ts, end_ts: fetchEndTs}) + if (!queriedRange) { + // first time loading + if(!isLoading) setIsLoading('replace'); + const nowTs = new Date().getTime() / 1000; + const [ctList, utList] = await fetchTripsInRange(pipelineRange.end_ts - ONE_WEEK, nowTs); + handleFetchedTrips(ctList, utList, 'replace'); + setQueriedRange({start_ts: pipelineRange.end_ts - ONE_WEEK, end_ts: nowTs}); + } else if (when == 'past' && !reachedPipelineStart) { + if(!isLoading) setIsLoading('prepend'); + const fetchStartTs = Math.max(queriedRange.start_ts - ONE_WEEK, pipelineRange.start_ts); + const [ctList, utList] = await fetchTripsInRange(queriedRange.start_ts - ONE_WEEK, queriedRange.start_ts - 1); + handleFetchedTrips(ctList, utList, 'prepend'); + setQueriedRange({start_ts: fetchStartTs, end_ts: queriedRange.end_ts}) + } else if (when == 'future' && !reachedPipelineEnd) { + if(!isLoading) setIsLoading('append'); + const fetchEndTs = Math.min(queriedRange.end_ts + ONE_WEEK, pipelineRange.end_ts); + const [ctList, utList] = await fetchTripsInRange(queriedRange.end_ts + 1, fetchEndTs); + handleFetchedTrips(ctList, utList, 'append'); + setQueriedRange({start_ts: queriedRange.start_ts, end_ts: fetchEndTs}) + } + } catch (e) { + setIsLoading(false); + displayError(e, t('errors.while-loading-another-week', {when: when})); } } async function loadSpecificWeek(day: string) { - if (!isLoading) setIsLoading('replace'); - resetNominatimLimiter(); - const threeDaysBefore = moment(day).subtract(3, 'days').unix(); - const threeDaysAfter = moment(day).add(3, 'days').unix(); - const [ctList, utList] = await fetchTripsInRange(threeDaysBefore, threeDaysAfter); - handleFetchedTrips(ctList, utList, 'replace'); - setQueriedRange({start_ts: threeDaysBefore, end_ts: threeDaysAfter}); + try { + if (!isLoading) setIsLoading('replace'); + resetNominatimLimiter(); + const threeDaysBefore = moment(day).subtract(3, 'days').unix(); + const threeDaysAfter = moment(day).add(3, 'days').unix(); + const [ctList, utList] = await fetchTripsInRange(threeDaysBefore, threeDaysAfter); + handleFetchedTrips(ctList, utList, 'replace'); + setQueriedRange({start_ts: threeDaysBefore, end_ts: threeDaysAfter}); + } catch (e) { + setIsLoading(false); + displayError(e, t('errors.while-loading-specific-week', {day: day})); + } } function handleFetchedTrips(ctList, utList, mode: 'prepend' | 'append' | 'replace') { const tripsRead = ctList.concat(utList); populateCompositeTrips(tripsRead, showPlaces, labelPopulateFactory, labelsResultMap, enbs, notesResultMap); - // Fill place names and trajectories on a reversed copy of the list so we fill from the bottom up + // Fill place names on a reversed copy of the list so we fill from the bottom up tripsRead.slice().reverse().forEach(function (trip, index) { - trip.geojson = Timeline.compositeTrip2Geojson(trip); fillLocationNamesOfTrip(trip); }); const readTimelineMap = compositeTrips2TimelineMap(tripsRead, showPlaces); @@ -215,19 +232,7 @@ const LabelTab = () => { function checkPermissionsStatus() { $rootScope.$broadcast("recomputeAppStatus", (status) => { if (!status) { - $ionicPopup.show({ - title: t('control.incorrect-app-status'), - template: t('control.fix-app-status'), - scope: $rootScope, - buttons: [{ - text: t('control.fix'), - type: 'button-assertive', - onTap: function (e) { - $state.go('root.main.control', { launchAppStatusModal: 1 }); - return false; - } - }] - }); + setPermissionVis(true); //if the status is false, popup modal } }); } @@ -258,6 +263,7 @@ const LabelTab = () => { const contextVals = { surveyOpt, + labelOptions, timelineMap, displayedEntries, filterInputs, @@ -284,6 +290,10 @@ const LabelTab = () => { This is what `detachPreviousScreen:false` does. */ options={{detachPreviousScreen: false}} /> + ); diff --git a/www/js/diary/addressNamesHelper.ts b/www/js/diary/addressNamesHelper.ts index 8023ccdab..e7f198fbe 100644 --- a/www/js/diary/addressNamesHelper.ts +++ b/www/js/diary/addressNamesHelper.ts @@ -1,7 +1,7 @@ /* This is a temporary solution; localstorage is not a good long-term option and we should be looking to other key-value storage options in the React Native ecosystem. */ -import { useEffect, useState, useRef, useContext } from 'react'; +import { useEffect, useState, useRef } from 'react'; export type Listener = (event: EventType) => void; diff --git a/www/js/diary/cards/DiaryCard.tsx b/www/js/diary/cards/DiaryCard.tsx index 5e0c007ba..f97a38e46 100644 --- a/www/js/diary/cards/DiaryCard.tsx +++ b/www/js/diary/cards/DiaryCard.tsx @@ -23,12 +23,12 @@ export const DiaryCard = ({ timelineEntry, children, flavoredTheme, ...otherProp - + {children} - + diff --git a/www/js/diary/cards/ModesIndicator.tsx b/www/js/diary/cards/ModesIndicator.tsx new file mode 100644 index 000000000..5211f7ed4 --- /dev/null +++ b/www/js/diary/cards/ModesIndicator.tsx @@ -0,0 +1,78 @@ +import React, { useContext } from 'react'; +import { View, StyleSheet } from 'react-native'; +import color from "color"; +import { LabelTabContext } from '../LabelTab'; +import { logDebug } from '../../plugin/logger'; +import { getBaseModeOfLabeledTrip } from '../diaryHelper'; +import { Icon } from '../../components/Icon'; +import { Text, useTheme } from 'react-native-paper'; +import { useTranslation } from 'react-i18next'; + +const ModesIndicator = ({ trip, detectedModes, }) => { + + const { t } = useTranslation(); + const { labelOptions } = useContext(LabelTabContext); + const { colors } = useTheme(); + + const indicatorBackgroundColor = color(colors.onPrimary).alpha(.8).rgb().string(); + let indicatorBorderColor = color('black').alpha(.5).rgb().string(); + + let modeViews; + if (trip.userInput.MODE) { + const baseMode = getBaseModeOfLabeledTrip(trip, labelOptions); + indicatorBorderColor = baseMode.color; + logDebug(`TripCard: got baseMode = ${JSON.stringify(baseMode)}`); + modeViews = ( + + + + {trip.userInput.MODE.text} + + + ); + } else if (detectedModes?.length > 1 || detectedModes?.length == 1 && detectedModes[0].mode != 'UNKNOWN') { + // show detected modes if there are more than one, or if there is only one and it's not UNKNOWN + modeViews = (<> + {t('diary.detected')} + {detectedModes?.map?.((pct, i) => ( + + + {/* show percents if there are more than one detected modes */} + {detectedModes?.length > 1 && + {pct.pct}% + } + + ))} + ); + } + + return modeViews && ( + + + {modeViews} + + + ) +}; + +const s = StyleSheet.create({ + modesIndicator: { + marginVertical: 5, + marginHorizontal: 'auto', + paddingHorizontal: 4, + justifyContent: 'center', + alignItems: 'center', + borderRadius: 50, + borderWidth: 1, + flexDirection: 'row', + flexWrap: 'wrap', + columnGap: 4, + }, + mode: { + flexDirection: 'row', + gap: 2, + }, +}); + +export default ModesIndicator; diff --git a/www/js/diary/cards/PlaceCard.tsx b/www/js/diary/cards/PlaceCard.tsx index 4d1c0b4b2..481eab4fb 100644 --- a/www/js/diary/cards/PlaceCard.tsx +++ b/www/js/diary/cards/PlaceCard.tsx @@ -15,9 +15,8 @@ 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"; +import StartEndLocations from "../components/StartEndLocations"; type Props = { place: {[key: string]: any} }; const PlaceCard = ({ place }: Props) => { diff --git a/www/js/diary/cards/TimestampBadge.tsx b/www/js/diary/cards/TimestampBadge.tsx index 9b32b9b07..da753ec76 100644 --- a/www/js/diary/cards/TimestampBadge.tsx +++ b/www/js/diary/cards/TimestampBadge.tsx @@ -2,10 +2,10 @@ Used in the label screen, on the trip, place, and/or untracked cards */ import React from "react"; -import { bool, string } from "prop-types"; -import { Badge, Text, useTheme } from "react-native-paper"; +import { StyleSheet } from "react-native"; +import { Badge, BadgeProps, Text, useTheme } from "react-native-paper"; -type Props = { +type Props = BadgeProps & { lightBg: boolean, time: string, date?: string, @@ -16,6 +16,9 @@ const TimestampBadge = ({ lightBg, time, date, ...otherProps }: Props) => { const textColor = lightBg ? 'black' : 'white'; return ( + // @ts-ignore Technically, Badge only accepts a string or number as its child, but we want + // to have different bold & light text styles for the time and date, so we pass in Text components. + // It works fine with Text components inside, so let's ignore the type error. {time} @@ -27,7 +30,7 @@ const TimestampBadge = ({ lightBg, time, date, ...otherProps }: Props) => { ); }; -const styles = { +const styles = StyleSheet.create({ badge: { flex: 1, paddingHorizontal: 6, @@ -39,16 +42,11 @@ const styles = { lineHeight: 18, }, time: { - fontWeight: 500, // medium / semibold + fontWeight: '500', // medium / semibold }, date: { - fontWeight: 300, // light - } -} -TimestampBadge.propTypes = { - lightBg: bool, - time: string, - date: string -} + fontWeight: '300', // light + }, +}); export default TimestampBadge; diff --git a/www/js/diary/cards/TripCard.tsx b/www/js/diary/cards/TripCard.tsx index 4585caf3c..5414b9228 100644 --- a/www/js/diary/cards/TripCard.tsx +++ b/www/js/diary/cards/TripCard.tsx @@ -6,7 +6,7 @@ import React, { useContext } from "react"; import { View, useWindowDimensions, StyleSheet } from 'react-native'; -import { Divider, Text, IconButton } from 'react-native-paper'; +import { Text, IconButton } from 'react-native-paper'; import LeafletView from "../../components/LeafletView"; import { useTranslation } from "react-i18next"; import MultilabelButtonGroup from "../../survey/multilabel/MultiLabelButtonGroup"; @@ -17,12 +17,12 @@ import AddedNotesList from "../../survey/enketo/AddedNotesList"; import { getTheme } from "../../appTheme"; 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"; +import StartEndLocations from "../components/StartEndLocations"; +import ModesIndicator from "./ModesIndicator"; +import { useGeojsonForTrip } from "../timelineHelper"; type Props = { trip: {[key: string]: any}}; const TripCard = ({ trip }: Props) => { @@ -31,16 +31,18 @@ const TripCard = ({ trip }: Props) => { const { width: windowWidth } = useWindowDimensions(); const { appConfig, loading } = useAppConfig(); const { displayStartTime, displayEndTime, displayDate, formattedDistance, - distanceSuffix, displayTime, percentages } = useDerivedProperties(trip); + distanceSuffix, displayTime, detectedModes } = useDerivedProperties(trip); let [ tripStartDisplayName, tripEndDisplayName ] = useAddressNames(trip); const navigation = useNavigation(); - const { surveyOpt } = useContext(LabelTabContext); + const { surveyOpt, labelOptions } = useContext(LabelTabContext); + const tripGeojson = useGeojsonForTrip(trip, labelOptions, trip?.userInput?.MODE?.value); const isDraft = trip.key.includes('UNPROCESSED'); const flavoredTheme = getTheme(isDraft ? 'draft' : undefined); function showDetail() { - navigation.navigate("label.details", { tripId: trip._id.$oid }); + const tripId = trip._id.$oid; + navigation.navigate("label.details", { tripId, flavoredTheme }); } const mapOpts = { zoomControl: false, dragging: false }; @@ -75,18 +77,11 @@ const TripCard = ({ trip }: Props) => { {/* left panel */} - - - {percentages?.map?.((pct, i) => ( - - - {pct.pct}% - - ))} - + {showAddNoteButton && { diff --git a/www/js/diary/StartEndLocations.tsx b/www/js/diary/components/StartEndLocations.tsx similarity index 84% rename from www/js/diary/StartEndLocations.tsx rename to www/js/diary/components/StartEndLocations.tsx index 75f3ea12a..8d1096fab 100644 --- a/www/js/diary/StartEndLocations.tsx +++ b/www/js/diary/components/StartEndLocations.tsx @@ -1,8 +1,7 @@ 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'; +import { View, ViewProps } from 'react-native'; +import { Icon } from '../../components/Icon'; +import { Text, Divider, useTheme } from 'react-native-paper'; type Props = { displayStartTime?: string, displayStartName: string, @@ -19,7 +18,7 @@ const StartEndLocations = (props: Props) => { return (<> {props.displayStartTime && - + {props.displayStartTime} } @@ -34,7 +33,7 @@ const StartEndLocations = (props: Props) => { {props.displayEndTime && - + {props.displayEndTime} } @@ -54,7 +53,7 @@ const s = { flexDirection: 'row', alignItems: 'center', justifyContent: centered ? 'center' : 'flex-start', - }), + } as ViewProps), locationIcon: (colors, iconSize, filled?) => ({ border: `2px solid ${colors.primary}`, borderRadius: 50, @@ -65,7 +64,7 @@ const s = { height: iconSize * 1.5, backgroundColor: filled ? colors.primary : colors.onPrimary, marginRight: 6, - }) + } as ViewProps) } export default StartEndLocations; diff --git a/www/js/diary/details/LabelDetailsScreen.tsx b/www/js/diary/details/LabelDetailsScreen.tsx new file mode 100644 index 000000000..ffed9a300 --- /dev/null +++ b/www/js/diary/details/LabelDetailsScreen.tsx @@ -0,0 +1,99 @@ +/* A screen to show details of a trip, including a recap of trip info, a full-size map, + listed sections of the trip, and a graph of speed during the trip. + Navigated to from the main LabelListScreen by clicking a trip card. */ + +import React, { useContext, useState } from "react"; +import { View, Modal, ScrollView, useWindowDimensions } from "react-native"; +import { PaperProvider, Appbar, SegmentedButtons, Button, Surface, Text, useTheme } from "react-native-paper"; +import { LabelTabContext } from "../LabelTab"; +import LeafletView from "../../components/LeafletView"; +import { useTranslation } from "react-i18next"; +import MultilabelButtonGroup from "../../survey/multilabel/MultiLabelButtonGroup"; +import UserInputButton from "../../survey/enketo/UserInputButton"; +import { useAddressNames } from "../addressNamesHelper"; +import { SafeAreaView } from "react-native-safe-area-context"; +import useDerivedProperties from "../useDerivedProperties"; +import StartEndLocations from "../components/StartEndLocations"; +import { useGeojsonForTrip } from "../timelineHelper"; +import TripSectionsDescriptives from "./TripSectionsDescriptives"; +import OverallTripDescriptives from "./OverallTripDescriptives"; +import ToggleSwitch from "../../components/ToggleSwitch"; + +const LabelScreenDetails = ({ route, navigation }) => { + + const { surveyOpt, timelineMap, labelOptions } = useContext(LabelTabContext); + const { t } = useTranslation(); + const { height: windowHeight } = useWindowDimensions(); + const { tripId, flavoredTheme } = route.params; + const trip = timelineMap.get(tripId); + const { colors } = flavoredTheme || useTheme(); + const { displayDate, displayStartTime, displayEndTime } = useDerivedProperties(trip); + const [ tripStartDisplayName, tripEndDisplayName ] = useAddressNames(trip); + + const [ modesShown, setModesShown ] = useState<'labeled'|'detected'>('labeled'); + const tripGeojson = useGeojsonForTrip(trip, labelOptions, modesShown=='labeled' && trip?.userInput?.MODE?.value); + const mapOpts = {minZoom: 3, maxZoom: 17}; + + const modal = ( + + + + { navigation.goBack() }} /> + + + + + + + + {/* MultiLabel or UserInput button, inline on one row */} + + {surveyOpt?.elementTag == 'multilabel' && + } + {surveyOpt?.elementTag == 'enketo-trip-button' + && } + + + {/* Full-size Leaflet map, with zoom controls */} + + + {/* If trip is labeled, show a toggle to switch between "Labeled Mode" and "Detected Modes" + otherwise, just show "Detected" */} + {trip?.userInput?.MODE?.value ? + setModesShown(v)} value={modesShown} density='medium' + buttons={[{label: t('diary.labeled-mode'), value: 'labeled'}, {label: t('diary.detected-modes'), value: 'detected'}]} /> + : + + } + + {/* section-by-section breakdown of duration, distance, and mode */} + + {/* Overall trip duration, distance, and modes. + Only show this when multiple sections are shown, and we are showing detected modes. + If we just showed the labeled mode or a single section, this would be redundant. */} + { modesShown == 'detected' && trip?.sections?.length > 1 && + + } + {/* TODO: show speed graph here */} + + + + + ); + if (route.params.flavoredTheme) { + return ( + + {modal} + + ); + } + return modal; +} + +export default LabelScreenDetails; diff --git a/www/js/diary/details/OverallTripDescriptives.tsx b/www/js/diary/details/OverallTripDescriptives.tsx new file mode 100644 index 000000000..3902c8afe --- /dev/null +++ b/www/js/diary/details/OverallTripDescriptives.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { View } from 'react-native'; +import { Text } from 'react-native-paper' +import useDerivedProperties from '../useDerivedProperties'; +import { Icon } from '../../components/Icon'; +import { useTranslation } from 'react-i18next'; + +const OverallTripDescriptives = ({ trip }) => { + + const { t } = useTranslation(); + const { displayStartTime, displayEndTime, displayTime, + formattedDistance, distanceSuffix, detectedModes } = useDerivedProperties(trip); + + return ( + + Overall + + + {displayTime} + {`${displayStartTime} - ${displayEndTime}`} + + + + {`${formattedDistance} ${distanceSuffix}`} + + + + {detectedModes?.map?.((pct, i) => ( + + + + {pct.pct}% + + + ))} + + + + ); +} + +export default OverallTripDescriptives; diff --git a/www/js/diary/details/TripSectionsDescriptives.tsx b/www/js/diary/details/TripSectionsDescriptives.tsx new file mode 100644 index 000000000..6d172fed4 --- /dev/null +++ b/www/js/diary/details/TripSectionsDescriptives.tsx @@ -0,0 +1,65 @@ +import React, { useContext } from 'react'; +import { View } from 'react-native'; +import { Text, useTheme } from 'react-native-paper' +import { Icon } from '../../components/Icon'; +import useDerivedProperties from '../useDerivedProperties'; +import { getBaseModeByKey, getBaseModeOfLabeledTrip } from '../diaryHelper'; +import { LabelTabContext } from '../LabelTab'; + +const TripSectionsDescriptives = ({ trip, showLabeledMode=false }) => { + + const { labelOptions } = useContext(LabelTabContext); + const { displayStartTime, displayTime, formattedDistance, + distanceSuffix, formattedSectionProperties } = useDerivedProperties(trip); + + const { colors } = useTheme(); + + let sections = formattedSectionProperties; + /* if we're only showing the labeled mode, or there are no sections (i.e. unprocessed trip), + we treat this as unimodal and use trip-level attributes to construct a single section */ + if (showLabeledMode && trip?.userInput?.MODE || !trip.sections?.length) { + let baseMode; + if (showLabeledMode && trip?.userInput?.MODE) { + baseMode = getBaseModeOfLabeledTrip(trip, labelOptions); + } else { + baseMode = getBaseModeByKey('UNPROCESSED'); + } + sections = [{ + startTime: displayStartTime, + duration: displayTime, + distance: formattedDistance, + color: baseMode.color, + icon: baseMode.icon, + text: showLabeledMode && trip.userInput?.MODE?.text, // label text only shown for labeled trips + }]; + } + return ( + + {sections.map((section, i) => ( + + + {section.duration} + {section.startTime} + + + + {`${section.distance} ${distanceSuffix}`} + + + + + {section.text && + + {section.text} + + } + + + ))} + + ); +} + +export default TripSectionsDescriptives; diff --git a/www/js/diary/diaryHelper.ts b/www/js/diary/diaryHelper.ts index 942268ec7..746d2014d 100644 --- a/www/js/diary/diaryHelper.ts +++ b/www/js/diary/diaryHelper.ts @@ -1,54 +1,74 @@ // here we have some helper functions used throughout the label tab // these functions are being gradually migrated out of services.js -import i18next from "i18next"; import moment from "moment"; import { DateTime } from "luxon"; -const modeColors = { +export const modeColors = { + pink: '#d43678', // oklch(59% 0.2 0) // e-car red: '#b9003d', // oklch(50% 0.37 15) // car orange: '#b25200', // oklch(55% 0.37 50) // air, hsr - green: '#007e46', // oklch(52% 0.37 155) // bike + green: '#007e46', // oklch(52% 0.37 155) // bike, e-biek, moped blue: '#0068a5', // oklch(50% 0.37 245) // walk periwinkle: '#5e45cd', // oklch(50% 0.2 285) // light rail, train, tram, subway magenta: '#8e35a1', // oklch(50% 0.18 320) // bus grey: '#484848', // oklch(40% 0 0) // unprocessed / unknown - taupe: '#7d5857', // oklch(50% 0.05 15) // ferry, trolleybus, nonstandard modes + taupe: '#7d5857', // oklch(50% 0.05 15) // ferry, trolleybus, user-defined modes } -type MotionType = { +type BaseMode = { name: string, icon: string, color: string } -const MotionTypes: {[k: string]: MotionType} = { + +// parallels the server-side MotionTypes enum: https://github.com/e-mission/e-mission-server/blob/94e7478e627fa8c171323662f951c611c0993031/emission/core/wrapper/motionactivity.py#L12 +type MotionTypeKey = 'IN_VEHICLE' | 'BICYCLING' | 'ON_FOOT' | 'STILL' | 'UNKNOWN' | 'TILTING' + | 'WALKING' | 'RUNNING' | 'NONE' | 'STOPPED_WHILE_IN_VEHICLE' | 'AIR_OR_HSR'; + +const BaseModes: {[k: string]: BaseMode} = { + // BEGIN MotionTypes IN_VEHICLE: { name: "IN_VEHICLE", icon: "speedometer", color: modeColors.red }, - ON_FOOT: { name: "ON_FOOT", icon: "walk", color: modeColors.blue }, BICYCLING: { name: "BICYCLING", icon: "bike", color: modeColors.green }, + ON_FOOT: { name: "ON_FOOT", icon: "walk", color: modeColors.blue }, UNKNOWN: { name: "UNKNOWN", icon: "help", color: modeColors.grey }, WALKING: { name: "WALKING", icon: "walk", color: modeColors.blue }, - CAR: { name: "CAR", icon: "car", color: modeColors.red }, AIR_OR_HSR: { name: "AIR_OR_HSR", icon: "airplane", color: modeColors.orange }, - // based on OSM routes/tags: + // END MotionTypes + CAR: { name: "CAR", icon: "car", color: modeColors.red }, + E_CAR: { name: "E_CAR", icon: "car-electric", color: modeColors.pink }, + E_BIKE: { name: "E_BIKE", icon: "bicycle-electric", color: modeColors.green }, + E_SCOOTER: { name: "E_SCOOTER", icon: "scooter-electric", color: modeColors.periwinkle }, + MOPED: { name: "MOPED", icon: "moped", color: modeColors.green }, + TAXI: { name: "TAXI", icon: "taxi", color: modeColors.red }, BUS: { name: "BUS", icon: "bus-side", color: modeColors.magenta }, + AIR: { name: "AIR", icon: "airplane", color: modeColors.orange }, LIGHT_RAIL: { name: "LIGHT_RAIL", icon: "train-car-passenger", color: modeColors.periwinkle }, TRAIN: { name: "TRAIN", icon: "train-car-passenger", color: modeColors.periwinkle }, TRAM: { name: "TRAM", icon: "fas fa-tram", color: modeColors.periwinkle }, SUBWAY: { name: "SUBWAY", icon: "subway-variant", color: modeColors.periwinkle }, FERRY: { name: "FERRY", icon: "ferry", color: modeColors.taupe }, TROLLEYBUS: { name: "TROLLEYBUS", icon: "bus-side", color: modeColors.taupe }, - UNPROCESSED: { name: "UNPROCESSED", icon: "help", color: modeColors.grey } -} + UNPROCESSED: { name: "UNPROCESSED", icon: "help", color: modeColors.grey }, + OTHER: { name: "OTHER", icon: "pencil-circle", color: modeColors.taupe }, +}; -type MotionTypeKey = keyof typeof MotionTypes; +type BaseModeKey = keyof typeof BaseModes; /** * @param motionName A string like "WALKING" or "MotionTypes.WALKING" - * @returns A MotionType object containing the name, icon, and color of the motion type + * @returns A BaseMode object containing the name, icon, and color of the motion type */ -export function motionTypeOf(motionName: MotionTypeKey | `MotionTypes.${MotionTypeKey}`) { +export function getBaseModeByKey(motionName: BaseModeKey | MotionTypeKey | `MotionTypes.${MotionTypeKey}`) { let key = ('' + motionName).toUpperCase(); key = key.split(".").pop(); // if "MotionTypes.WALKING", then just take "WALKING" - return MotionTypes[key] || MotionTypes.UNKNOWN; + return BaseModes[key] || BaseModes.UNKNOWN; +} + +export function getBaseModeOfLabeledTrip(trip, labelOptions) { + const modeKey = trip?.userInput?.MODE?.value; + if (!modeKey) return null; // trip has no MODE label + const modeOption = labelOptions?.MODE?.find(opt => opt.value == modeKey); + return getBaseModeByKey(modeOption?.baseMode || "OTHER"); } /** @@ -109,12 +129,12 @@ export function getFormattedTimeRange(beginFmtTime: string, endFmtTime: string) return endMoment.to(beginMoment, true); }; -// Temporary function to avoid repear in getPercentages ret val. +// Temporary function to avoid repear in getDetectedModes ret val. const filterRunning = (mode) => (mode == 'MotionTypes.RUNNING') ? 'MotionTypes.WALKING' : mode; -export function getPercentages(trip) { - if (!trip.sections?.length) return {}; +export function getDetectedModes(trip) { + if (!trip.sections?.length) return []; // sum up the distances for each mode, as well as the total distance let totalDist = 0; @@ -131,8 +151,8 @@ export function getPercentages(trip) { const fract = dists[mode] / totalDist; return { mode: mode, - icon: motionTypeOf(mode)?.icon, - color: motionTypeOf(mode)?.color || 'black', + icon: getBaseModeByKey(mode)?.icon, + color: getBaseModeByKey(mode)?.color || 'black', pct: Math.round(fract * 100) || '<1' // if rounds to 0%, show <1% }; }); @@ -142,12 +162,12 @@ export function getPercentages(trip) { 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", + startTime: getLocalTimeString(s.start_local_dt), + duration: getFormattedTimeRange(s.start_fmt_time, s.end_fmt_time), + distance: ImperialConfig.getFormattedDistance(s.distance), + distanceSuffix: ImperialConfig.distanceSuffix, + icon: getBaseModeByKey(s.sensed_mode_str)?.icon, + color: getBaseModeByKey(s.sensed_mode_str)?.color || "#333", })); } diff --git a/www/js/diary/diaryTypes.ts b/www/js/diary/diaryTypes.ts index 84c34468b..bcaeb83ae 100644 --- a/www/js/diary/diaryTypes.ts +++ b/www/js/diary/diaryTypes.ts @@ -56,7 +56,7 @@ export type DerivedProperties = { formattedDistance: string, formattedSectionProperties: any[], // TODO distanceSuffix: string, - percentages: { mode: string, icon: string, color: string, pct: number|string }[], + detectedModes: { mode: string, icon: string, color: string, pct: number|string }[], } /* These are the properties that are still filled in by some kind of 'populate' mechanism. diff --git a/www/js/diary/list/DateSelect.tsx b/www/js/diary/list/DateSelect.tsx index a75e685f7..1c28cdc2c 100644 --- a/www/js/diary/list/DateSelect.tsx +++ b/www/js/diary/list/DateSelect.tsx @@ -7,11 +7,11 @@ */ import React, { useEffect, useState, useMemo, useContext } from "react"; -import { Text, StyleSheet } from "react-native"; +import { StyleSheet } from "react-native"; import moment from "moment"; import { LabelTabContext } from "../LabelTab"; import { DatePickerModal } from "react-native-paper-dates"; -import { Divider, useTheme } from "react-native-paper"; +import { Text, Divider, useTheme } from "react-native-paper"; import i18next from "i18next"; import { useTranslation } from "react-i18next"; import NavBarButton from "../../components/NavBarButton"; @@ -71,20 +71,20 @@ const DateSelect = ({ tsRange, loadSpecificWeekFn }) => { {dateRangeEnd} + onChange={onChoose} + onConfirm={onDismissSingle} /> ); }; export const s = StyleSheet.create({ divider: { - width: '3ch', + width: 25, marginHorizontal: 'auto', } }); diff --git a/www/js/diary/LabelListScreen.tsx b/www/js/diary/list/LabelListScreen.tsx similarity index 87% rename from www/js/diary/LabelListScreen.tsx rename to www/js/diary/list/LabelListScreen.tsx index 3801d88a9..0ed5f702b 100644 --- a/www/js/diary/LabelListScreen.tsx +++ b/www/js/diary/list/LabelListScreen.tsx @@ -1,10 +1,10 @@ import React, { useContext } from "react"; import { View } from "react-native"; import { Appbar, useTheme } from "react-native-paper"; -import DateSelect from "./list/DateSelect"; -import FilterSelect from "./list/FilterSelect"; -import TimelineScrollList from "./list/TimelineScrollList"; -import { LabelTabContext } from "./LabelTab"; +import DateSelect from "./DateSelect"; +import FilterSelect from "./FilterSelect"; +import TimelineScrollList from "./TimelineScrollList"; +import { LabelTabContext } from "../LabelTab"; const LabelListScreen = () => { diff --git a/www/js/diary/services.js b/www/js/diary/services.js index 9c89e3725..0891b9103 100644 --- a/www/js/diary/services.js +++ b/www/js/diary/services.js @@ -1,7 +1,7 @@ 'use strict'; import angular from 'angular'; -import { getFormattedTimeRange, motionTypeOf } from './diaryHelper'; +import { getBaseModeByKey, getBaseModeOfLabeledTrip } from './diaryHelper'; import { SurveyOptions } from '../survey/survey'; angular.module('emission.main.diary.services', ['emission.plugin.logger', @@ -220,69 +220,6 @@ angular.module('emission.main.diary.services', ['emission.plugin.logger', return e1.data.ts - e2.data.ts; } - var confirmedPlace2Geojson = function(trip, locationPoint, featureType) { - var place_gj = { - "type": "Feature", - "geometry": locationPoint, - "properties": { - "feature_type": featureType - } - } - return place_gj; - } - - var confirmedPoints2Geojson = function(trip, locationList) { - let sectionsPoints; - if (!trip.sections) { - sectionsPoints = [locationList]; - } else { - sectionsPoints = trip.sections.map((s) => - trip.locations.filter((l) => - l.ts >= s.start_ts && l.ts <= s.end_ts - ) - ); - } - - return sectionsPoints.map((sectionPoints, i) => { - const section = trip.sections?.[i]; - return { - type: "Feature", - geometry: { - type: "LineString", - coordinates: sectionPoints.map((pt) => pt.loc.coordinates) - }, - style: { - color: motionTypeOf(section?.sensed_mode_str)?.color || "#333", - } - } - }); - } - - timeline.compositeTrip2Geojson = function(trip) { - if (trip == undefined) { - return undefined; - } - - Logger.log("Reading trip's " + trip.locations.length + " location points at " + (new Date())); - var features = [ - confirmedPlace2Geojson(trip, trip.start_loc, "start_place"), - confirmedPlace2Geojson(trip, trip.end_loc, "end_place"), - ...confirmedPoints2Geojson(trip, trip.locations) - ]; - - return { - data: { - id: "confirmed" + trip.start_ts, - type: "FeatureCollection", - features: features, - properties: { - start_ts: trip.start_ts, - end_ts: trip.end_ts - } - } - } - } - var transitionTrip2TripObj = function(trip) { var tripStartTransition = trip[0]; var tripEndTransition = trip[1]; @@ -328,8 +265,14 @@ angular.module('emission.main.diary.services', ['emission.plugin.logger', return { ...tripProps, - start_loc: tripStartPoint.data.loc, - end_loc: tripEndPoint.data.loc, + start_loc: { + type: "Point", + coordinates: [tripStartPoint.data.longitude, tripStartPoint.data.latitude] + }, + end_loc: { + type: "Point", + coordinates: [tripEndPoint.data.longitude, tripEndPoint.data.latitude], + }, } }); } @@ -390,8 +333,12 @@ angular.module('emission.main.diary.services', ['emission.plugin.logger', // anyway. Logger.log("mapped trips to trip_gj_list of size "+raw_trip_gj_list.length); - var trip_gj_list = raw_trip_gj_list.filter(angular.isDefined); - Logger.log("after filtering undefined, trip_gj_list size = "+raw_trip_gj_list.length); + /* Filtering: we will keep trips that are 1) defined and 2) have a distance >= 100m or duration >= 5 minutes + https://github.com/e-mission/e-mission-docs/issues/966#issuecomment-1709112578 */ + const trip_gj_list = raw_trip_gj_list.filter((trip) => + trip && (trip.distance >= 100 || trip.duration >= 300) + ); + Logger.log("after filtering undefined and distance < 100, trip_gj_list size = "+raw_trip_gj_list.length); // Link 0th trip to first, first to second, ... for (var i = 0; i < trip_gj_list.length-1; i++) { linkTrips(trip_gj_list[i], trip_gj_list[i+1]); diff --git a/www/js/diary/timelineHelper.ts b/www/js/diary/timelineHelper.ts index f37a1246b..579e3ac4f 100644 --- a/www/js/diary/timelineHelper.ts +++ b/www/js/diary/timelineHelper.ts @@ -1,6 +1,46 @@ import moment from "moment"; import { getAngularService } from "../angular-react-helper"; -import { getFormattedDate, getFormattedDateAbbr, getFormattedTimeRange, getLocalTimeString, getPercentages, isMultiDay } from "./diaryHelper"; +import { displayError, logDebug } from "../plugin/logger"; +import { getBaseModeByKey, getBaseModeOfLabeledTrip } from "./diaryHelper"; +import i18next from "i18next"; + +const cachedGeojsons = new Map(); +/** + * @description Gets a formatted GeoJSON object for a trip, including the start and end places and the trajectory. + */ +export function useGeojsonForTrip(trip, labelOptions, labeledMode?) { + if (!trip) return; + const gjKey = `trip-${trip._id.$oid}-${labeledMode || 'detected'}`; + if (cachedGeojsons.has(gjKey)) { + return cachedGeojsons.get(gjKey); + } + + let trajectoryColor: string|null; + if (labeledMode) { + trajectoryColor = getBaseModeOfLabeledTrip(trip, labelOptions)?.color; + } + + logDebug("Reading trip's " + trip.locations.length + " location points at " + (new Date())); + var features = [ + location2GeojsonPoint(trip.start_loc, "start_place"), + location2GeojsonPoint(trip.end_loc, "end_place"), + ...locations2GeojsonTrajectory(trip, trip.locations, trajectoryColor) + ]; + + const gj = { + data: { + id: gjKey, + type: "FeatureCollection", + features: features, + properties: { + start_ts: trip.start_ts, + end_ts: trip.end_ts + } + } + } + cachedGeojsons.set(gjKey, gj); + return gj; +} /** * @description Unpacks composite trips into a Map object of timeline items, by id. @@ -31,25 +71,29 @@ export function compositeTrips2TimelineMap(ctList: any[], unpackPlaces?: boolean } 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]; - labelsFactory.populateInputsAndInferences(cp, labelsResultMap); - notesFactory.populateInputsAndInferences(cp, notesResultMap); - } - if (showPlaces && ct.end_confirmed_place) { - const cp = ct.end_confirmed_place; - cp.getNextEntry = () => ctList[i + 1]; - labelsFactory.populateInputsAndInferences(cp, labelsResultMap); - notesFactory.populateInputsAndInferences(cp, notesResultMap); - ct.getNextEntry = () => cp; - } else { - ct.getNextEntry = () => ctList[i + 1]; - } - labelsFactory.populateInputsAndInferences(ct, labelsResultMap); - notesFactory.populateInputsAndInferences(ct, notesResultMap); - }); + try { + ctList.forEach((ct, i) => { + if (showPlaces && ct.start_confirmed_place) { + const cp = ct.start_confirmed_place; + cp.getNextEntry = () => ctList[i]; + labelsFactory.populateInputsAndInferences(cp, labelsResultMap); + notesFactory.populateInputsAndInferences(cp, notesResultMap); + } + if (showPlaces && ct.end_confirmed_place) { + const cp = ct.end_confirmed_place; + cp.getNextEntry = () => ctList[i + 1]; + labelsFactory.populateInputsAndInferences(cp, labelsResultMap); + notesFactory.populateInputsAndInferences(cp, notesResultMap); + ct.getNextEntry = () => cp; + } else { + ct.getNextEntry = () => ctList[i + 1]; + } + labelsFactory.populateInputsAndInferences(ct, labelsResultMap); + notesFactory.populateInputsAndInferences(ct, notesResultMap); + }); + } catch (e) { + displayError(e, i18next.t('errors.while-populating-composite')); + } } const getUnprocessedInputQuery = (pipelineRange) => ({ @@ -113,3 +157,56 @@ export function getAllUnprocessedInputs(pipelineRange, labelsFactory, notesFacto ); return getUnprocessedResults(labelsFactory, notesFactory, labelsPromises, notesPromises); } + +/** + * @param locationPoint an object containing coordinates as array of [lat, lon] + * @param featureType a string describing the feature, e.g. "start_place" + * @returns a GeoJSON feature with type "Point", the given location's coordinates and the given feature type + */ +const location2GeojsonPoint = (locationPoint: any, featureType: string) => ({ + type: "Feature", + geometry: { + type: "Point", + coordinates: locationPoint.coordinates, + }, + properties: { + feature_type: featureType, + } +}); + +/** + * @param trip + * @param locationList an array of locations to use for the trajectory. + * @param trajectoryColor The color to use for the whole trajectory, if any. Otherwise, a color will be lookup up for the sensed mode of each section. + * @returns for each section of the trip, a GeoJSON feature with type "LineString" and an array of coordinates. + */ +const locations2GeojsonTrajectory = (trip, locationList, trajectoryColor?) => { + let sectionsPoints; + if (!trip.sections) { + // this is a unimodal trip so we put all the locations in one section + sectionsPoints = [locationList]; + } else { + // this is a multimodal trip so we sort the locations into sections by timestamp + sectionsPoints = trip.sections.map((s) => + trip.locations.filter((l) => + l.ts >= s.start_ts && l.ts <= s.end_ts + ) + ); + } + + return sectionsPoints.map((sectionPoints, i) => { + const section = trip.sections?.[i]; + return { + type: "Feature", + geometry: { + type: "LineString", + coordinates: sectionPoints.map((pt) => pt.loc.coordinates), + }, + style: { + /* If a color was passed as arg, use it for the whole trajectory. Otherwise, use the + color for the sensed mode of this section, and fall back to dark grey */ + color: trajectoryColor || getBaseModeByKey(section?.sensed_mode_str)?.color || "#333", + }, + } + }); +} diff --git a/www/js/diary/useDerivedProperties.tsx b/www/js/diary/useDerivedProperties.tsx index f929544af..604fef227 100644 --- a/www/js/diary/useDerivedProperties.tsx +++ b/www/js/diary/useDerivedProperties.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { useImperialConfig } from "../config/useImperialConfig"; -import { getFormattedDate, getFormattedDateAbbr, getFormattedSectionProperties, getFormattedTimeRange, getLocalTimeString, getPercentages, isMultiDay } from "./diaryHelper"; +import { getFormattedDate, getFormattedDateAbbr, getFormattedSectionProperties, getFormattedTimeRange, getLocalTimeString, getDetectedModes, isMultiDay } from "./diaryHelper"; const useDerivedProperties = (tlEntry) => { @@ -23,7 +23,7 @@ const useDerivedProperties = (tlEntry) => { formattedDistance: imperialConfig.getFormattedDistance(tlEntry.distance), formattedSectionProperties: getFormattedSectionProperties(tlEntry, imperialConfig), distanceSuffix: imperialConfig.distanceSuffix, - percentages: getPercentages(tlEntry), + detectedModes: getDetectedModes(tlEntry), } }, [tlEntry, imperialConfig]); } diff --git a/www/js/intro.js b/www/js/intro.js index 5d74e7a5d..0bc7fa01d 100644 --- a/www/js/intro.js +++ b/www/js/intro.js @@ -164,6 +164,7 @@ angular.module('emission.intro', ['emission.splash.startprefs', var prepopulateQRMessage = {}; prepopulateQRMessage.files = [pngFile]; prepopulateQRMessage.url = $scope.currentToken; + prepopulateQRMessage.message = $scope.currentToken; window.plugins.socialsharing.shareWithOptions(prepopulateQRMessage, function(result) { console.log("Share completed? " + result.completed); // On Android apps mostly return false even while it's true diff --git a/www/js/main.js b/www/js/main.js index 652d46054..79bfdee0d 100644 --- a/www/js/main.js +++ b/www/js/main.js @@ -37,7 +37,7 @@ angular.module('emission.main', ['emission.main.diary', }, views: { 'main-control': { - templateUrl: 'templates/control/main-control.html', + template: ``, controller: 'ControlCtrl' } } diff --git a/www/js/metrics-factory.js b/www/js/metrics-factory.js index e8739d412..04bd7989d 100644 --- a/www/js/metrics-factory.js +++ b/www/js/metrics-factory.js @@ -130,10 +130,10 @@ angular.module('emission.main.metrics.factory', {low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint}]; } else { console.log("Found range_limited_motorized mode", rlm); - const lowestMotorizedNonAir = getLowestMotorizedNonAirFootprint(customFootprint, rlm.co2PerMeter); + const lowestMotorizedNonAir = getLowestMotorizedNonAirFootprint(customFootprint, rlm.kgCo2PerKm); return [ {low: 0, high: FIVE_KM, optimal: 0}, - {low: FIVE_KM, high: rlm.range_limit_km * 1000, optimal: rlm.co2PerMeter}, + {low: FIVE_KM, high: rlm.range_limit_km * 1000, optimal: rlm.kgCo2PerKm}, {low: rlm.range_limit_km * 1000, high: SIX_HUNDRED_KM, optimal: lowestMotorizedNonAir}, {low: SIX_HUNDRED_KM, high: Number.MAX_VALUE, optimal: airFootprint}]; } diff --git a/www/js/metrics-mappings.js b/www/js/metrics-mappings.js index 1826b8a16..e8840bd8c 100644 --- a/www/js/metrics-mappings.js +++ b/www/js/metrics-mappings.js @@ -323,8 +323,8 @@ angular.module('emission.main.metrics.mappings', ['emission.plugin.logger', }; cdh.getCustomFootprint = function() { - console.log("Getting custom footprint", cdh.customPerMeterFootprint); - return cdh.customPerMeterFootprint; + console.log("Getting custom footprint", cdh.customPerKmFootprint); + return cdh.customPerKmFootprint; }; cdh.populateCustomMETs = function() { @@ -359,7 +359,7 @@ angular.module('emission.main.metrics.mappings', ['emission.plugin.logger', cdh.populateCustomFootprints = function() { let modeOptions = cdh.inputParams["MODE"]; - let modeCO2PerMeter = modeOptions.map((opt) => { + let modeCO2PerKm = modeOptions.map((opt) => { if (opt.range_limit_km) { if (cdh.range_limited_motorized) { Logger.displayError("Found two range limited motorized options", { @@ -368,14 +368,14 @@ angular.module('emission.main.metrics.mappings', ['emission.plugin.logger', cdh.range_limited_motorized = opt; console.log("Found range limited motorized mode", cdh.range_limited_motorized); } - if (angular.isDefined(opt.co2PerMeter)) { - return [opt.value, opt.co2PerMeter]; + if (angular.isDefined(opt.kgCo2PerKm)) { + return [opt.value, opt.kgCo2PerKm]; } else { return undefined; } }).filter((modeCO2) => angular.isDefined(modeCO2));; - cdh.customPerMeterFootprint = Object.fromEntries(modeCO2PerMeter); - console.log("After populating, custom perMeterFootprint", cdh.customPerMeterFootprint); + cdh.customPerKmFootprint = Object.fromEntries(modeCO2PerKm); + console.log("After populating, custom perKmFootprint", cdh.customPerKmFootprint); } cdh.init = function(newConfig) { diff --git a/www/js/plugin/logger.ts b/www/js/plugin/logger.ts index 05e684bbc..c4e476de1 100644 --- a/www/js/plugin/logger.ts +++ b/www/js/plugin/logger.ts @@ -43,7 +43,7 @@ export function displayErrorMsg(errorMsg: string, title?: string) { if (errorMsg.includes?.("403")) { title = "Invalid OPcode: " + (title || ''); } - const displayMsg = title ? title + '\n' + errorMsg : errorMsg; + const displayMsg = `━━━━\n${title}\n━━━━\n` + 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 1fccda3e9..069af7a18 100644 --- a/www/js/splash/notifScheduler.js +++ b/www/js/splash/notifScheduler.js @@ -49,16 +49,17 @@ angular.module('emission.splash.notifscheduler', return true; } - const setUpActions = () => { - const action = { - id: 'action', - title: 'Change Time', - launch: true - }; - return new Promise((rs) => { - cordova.plugins.notification.local.addActions('reminder-actions', [action], rs); - }); - } + /* remove notif actions as they do not work, can restore post routing migration */ + // const setUpActions = () => { + // const action = { + // id: 'action', + // title: 'Change Time', + // launch: true + // }; + // return new Promise((rs) => { + // cordova.plugins.notification.local.addActions('reminder-actions', [action], rs); + // }); + // } function debugGetScheduled(prefix) { cordova.plugins.notification.local.getScheduled((notifs) => { @@ -141,15 +142,15 @@ angular.module('emission.splash.notifscheduler', title: scheme.title[localeCode], text: scheme.text[localeCode], trigger: {at: nDate}, - actions: 'reminder-actions', - data: { - action: { - redirectTo: 'root.main.control', - redirectParams: { - openTimeOfDayPicker: true - } - } - } + // actions: 'reminder-actions', + // data: { + // action: { + // redirectTo: 'root.main.control', + // redirectParams: { + // openTimeOfDayPicker: true + // } + // } + // } } }); cordova.plugins.notification.local.cancelAll(() => { @@ -262,7 +263,7 @@ angular.module('emission.splash.notifscheduler', Logger.log("No reminder schemes found in config, not scheduling notifications"); return; } - setUpActions(); + //setUpActions(); update(); }); diff --git a/www/js/survey/enketo/AddNoteButton.tsx b/www/js/survey/enketo/AddNoteButton.tsx index dc82b5577..1b85c728e 100644 --- a/www/js/survey/enketo/AddNoteButton.tsx +++ b/www/js/survey/enketo/AddNoteButton.tsx @@ -8,11 +8,12 @@ */ import React, { useEffect, useState, useContext } from "react"; -import DiaryButton from "../../diary/DiaryButton"; +import DiaryButton from "../../components/DiaryButton"; import { useTranslation } from "react-i18next"; import moment from "moment"; import { LabelTabContext } from "../../diary/LabelTab"; import EnketoModal from "./EnketoModal"; +import { displayErrorMsg, logDebug } from "../../plugin/logger"; type Props = { timelineEntry: any, @@ -83,10 +84,10 @@ const AddNoteButton = ({ timelineEntry, notesConfig, storeKey }: Props) => { function onResponseSaved(result) { if (result) { - console.log('AddNoteButton: response was saved, about to repopulateTimelineEntry; result=', result); + logDebug('AddNoteButton: response was saved, about to repopulateTimelineEntry; result=' + JSON.stringify(result)); repopulateTimelineEntry(timelineEntry._id.$oid); } else { - console.error('AddNoteButton: response was not saved, result=', result); + displayErrorMsg('AddNoteButton: response was not saved, result=', result); } } diff --git a/www/js/survey/enketo/AddedNotesList.tsx b/www/js/survey/enketo/AddedNotesList.tsx index d204f45f3..e29278cca 100644 --- a/www/js/survey/enketo/AddedNotesList.tsx +++ b/www/js/survey/enketo/AddedNotesList.tsx @@ -4,12 +4,13 @@ import React, { useContext, useState } from "react"; import moment from "moment"; -import { Text, Modal } from "react-native" -import { Button, DataTable, Dialog } from "react-native-paper"; +import { Modal } from "react-native" +import { Text, Button, DataTable, Dialog } from "react-native-paper"; import { LabelTabContext } from "../../diary/LabelTab"; import { getFormattedDateAbbr, isMultiDay } from "../../diary/diaryHelper"; import { Icon } from "../../components/Icon"; import EnketoModal from "./EnketoModal"; +import { useTranslation } from "react-i18next"; type Props = { timelineEntry: any, @@ -17,6 +18,7 @@ type Props = { } const AddedNotesList = ({ timelineEntry, additionEntries }: Props) => { + const { t } = useTranslation(); const { repopulateTimelineEntry } = useContext(LabelTabContext); const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] = useState(false); const [surveyModalVisible, setSurveyModalVisible] = useState(false); @@ -33,7 +35,7 @@ const AddedNotesList = ({ timelineEntry, additionEntries }: Props) => { if (isMultiDay(beginTs, stopTs)) { const beginTsZoned = moment.parseZone(beginTs*1000).tz(timezone); const stopTsZoned = moment.parseZone(stopTs*1000).tz(timezone); - d = getFormattedDateAbbr(beginTsZoned.unix(), stopTsZoned.unix()); + d = getFormattedDateAbbr(beginTsZoned.toISOString(), stopTsZoned.toISOString()); } const begin = moment.parseZone(beginTs*1000).tz(timezone).format('LT'); const stop = moment.parseZone(stopTs*1000).tz(timezone).format('LT'); @@ -122,7 +124,7 @@ const AddedNotesList = ({ timelineEntry, additionEntries }: Props) => { }} /> - Are you sure you wish to delete this entry? + { t('diary.delete-entry-confirm') } {editingEntry?.data?.label} {editingEntry?.displayDt?.date} diff --git a/www/js/survey/enketo/EnketoModal.tsx b/www/js/survey/enketo/EnketoModal.tsx index 326f8069b..b4bf8f024 100644 --- a/www/js/survey/enketo/EnketoModal.tsx +++ b/www/js/survey/enketo/EnketoModal.tsx @@ -9,7 +9,7 @@ import { fetchUrlCached } from '../../commHelper'; import { displayError, displayErrorMsg } from '../../plugin/logger'; // import { transform } from 'enketo-transformer/web'; -type Props = ModalProps & { +type Props = Omit & { surveyName: string, onResponseSaved: (response: any) => void, opts?: SurveyOptions, diff --git a/www/js/survey/enketo/UserInputButton.tsx b/www/js/survey/enketo/UserInputButton.tsx index d3d87d8bb..68d0ae944 100644 --- a/www/js/survey/enketo/UserInputButton.tsx +++ b/www/js/survey/enketo/UserInputButton.tsx @@ -8,13 +8,14 @@ The start and end times of the addition are the same as the trip or place. */ -import React, { useEffect, useState } from "react"; +import React, { useContext, useMemo, useState } from "react"; import { getAngularService } from "../../angular-react-helper"; -import DiaryButton from "../../diary/DiaryButton"; +import DiaryButton from "../../components/DiaryButton"; import { useTranslation } from "react-i18next"; import { useTheme } from "react-native-paper"; -import { logDebug } from "../../plugin/logger"; +import { displayErrorMsg, logDebug } from "../../plugin/logger"; import EnketoModal from "./EnketoModal"; +import { LabelTabContext } from "../../diary/LabelTab"; type Props = { timelineEntry: any, @@ -23,22 +24,17 @@ 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 { repopulateTimelineEntry } = useContext(LabelTabContext); const EnketoTripButtonService = getAngularService("EnketoTripButtonService"); const etbsSingleKey = EnketoTripButtonService.SINGLE_KEY; - useEffect(() => { - const isFilled = timelineEntry.userInput?.[etbsSingleKey]; - if (isFilled) { - setDisplayLabel(timelineEntry.userInput[etbsSingleKey].data.label); - setIsFilled(true); - } - }, []); + // the label resolved from the survey response, or null if there is no response yet + const responseLabel = useMemo(() => ( + timelineEntry.userInput?.[etbsSingleKey]?.data?.label || null + ), [timelineEntry]); function launchUserInputSurvey() { logDebug('UserInputButton: About to launch survey'); @@ -48,19 +44,19 @@ const UserInputButton = ({ timelineEntry }: Props) => { } function onResponseSaved(result) { - if (!result) return; - timelineEntry.userInput[etbsSingleKey] = { - data: result, - write_ts: Date.now() + if (result) { + logDebug('UserInputButton: response was saved, about to repopulateTimelineEntry; result=' + JSON.stringify(result)); + repopulateTimelineEntry(timelineEntry._id.$oid); + } else { + displayErrorMsg('UserInputButton: response was not saved, result=', result); } - setDisplayLabel(result.label); - setIsFilled(true); } return (<> - launchUserInputSurvey()}> - {displayLabel} + {/* if no response yet, show the default label */} + {responseLabel || t('diary.choose-survey')} { +const MultilabelButtonGroup = ({ trip, buttonsInline=false }) => { const { colors } = useTheme(); const { t } = useTranslation(); - const { repopulateTimelineEntry } = useContext(LabelTabContext); + const { repopulateTimelineEntry, labelOptions } = useContext(LabelTabContext); const { height: windowHeight } = useWindowDimensions(); - const [ inputParams, setInputParams ] = useState({}); // 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); @@ -27,18 +26,13 @@ const MultilabelButtonGroup = ({ trip }) => { return trip.userInput[modalVisibleFor]?.value }, [modalVisibleFor, otherLabel]); - useEffect(() => { - console.log("During initialization, trip is ", trip); - getLabelOptions().then((ip) => setInputParams(ip)); - }, []); - // to mark 'inferred' labels as 'confirmed'; turn yellow labels blue function verifyTrip() { 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") + if (inferred?.value && !trip.userInput[inputType]) { store(inputType, inferred.value, false); + } } } @@ -61,7 +55,7 @@ const MultilabelButtonGroup = ({ trip }) => { if (isOther) { /* Let's make the value for user entered inputs look consistent with our other values (i.e. lowercase, and with underscores instead of spaces) */ - chosenLabel = chosenLabel.toLowerCase().replace(" ", "_"); + chosenLabel = readableLabelToKey(chosenLabel); } const inputDataToStore = { "start_ts": trip.start_ts, @@ -80,24 +74,26 @@ const MultilabelButtonGroup = ({ trip }) => { const inputKeys = Object.keys(trip.inputDetails); return (<> - + {inputKeys.map((key, i) => { const input = trip.inputDetails[key]; const inputIsConfirmed = trip.userInput[input.name]; const inputIsInferred = trip.finalInference[input.name]; - let fillColor; + let fillColor, textColor, borderColor; if (inputIsConfirmed) { fillColor = colors.primary; } else if (inputIsInferred) { - fillColor = colors.secondary; + fillColor = colors.secondaryContainer; + borderColor = colors.secondary; + textColor = colors.onSecondaryContainer; } const btnText = inputIsConfirmed?.text || inputIsInferred?.text || input.choosetext; return ( - + {t(input.labeltext)} - setModalVisibleFor(input.name)}> + setModalVisibleFor(input.name)}> { t(btnText) } @@ -110,8 +106,8 @@ const MultilabelButtonGroup = ({ trip }) => { style={{width: 20, height: 20, margin: 3}}/> - dismiss()}> - dismiss()}> + dismiss()}> + dismiss()}> {(modalVisibleFor == 'MODE') && t('diary.select-mode-scroll') || @@ -121,7 +117,7 @@ const MultilabelButtonGroup = ({ trip }) => { onChooseLabel(val)} value={chosenLabel}> - {inputParams?.[modalVisibleFor]?.map((o, i) => ( + {labelOptions?.[modalVisibleFor]?.map((o, i) => ( // @ts-ignore ))} diff --git a/www/js/survey/multilabel/confirmHelper.ts b/www/js/survey/multilabel/confirmHelper.ts index d5e43b826..fdfea319f 100644 --- a/www/js/survey/multilabel/confirmHelper.ts +++ b/www/js/survey/multilabel/confirmHelper.ts @@ -15,11 +15,12 @@ type InputDetails = { }; type LabelOptions = { [k in T]: { - key: string, + value: string, + baseMode: string, met?: {range: any[], mets: number} - met_equivalent: string, - co2PerMeter: number, - } + met_equivalent?: string, + kgCo2PerKm: number, + }[] } & { translations: { [lang: string]: { [translationKey: string]: string } }}; @@ -39,8 +40,8 @@ export async function getLabelOptions(appConfigParam?) { according to the current language */ const lang = i18next.language; for (const opt in labelOptions) { - labelOptions[opt].forEach((o, i) => { - const translationKey = o.key; + labelOptions[opt]?.forEach?.((o, i) => { + const translationKey = o.value; const translation = labelOptions.translations[lang][translationKey]; labelOptions[opt][i].text = translation; }); @@ -50,12 +51,12 @@ export async function getLabelOptions(appConfigParam?) { 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; + const optionJson = await fetch(optionFileName).then(r => r.json()); + labelOptions = optionJson as LabelOptions; } catch (e) { logDebug("error "+JSON.stringify(e)+" while reading confirm options, reverting to defaults"); - const optionFile = await fetchUrlCached("json/trip_confirm_options.json.sample"); - labelOptions = JSON.parse(optionFile) as LabelOptions; + const optionJson = await fetch("json/trip_confirm_options.json.sample").then(r => r.json()); + labelOptions = optionJson as LabelOptions; } } return labelOptions; @@ -80,8 +81,9 @@ 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 + if (!appConfig.intro.mode_studied) { + /* If there is no mode of interest, we don't need REPLACED_MODE. + So just return the base input details. */ return baseLabelInputDetails; } // else this is a program, so add the REPLACED_MODE @@ -99,18 +101,20 @@ export function getLabelInputDetails(appConfigParam?) { export const getLabelInputs = () => Object.keys(getLabelInputDetails()); export const getBaseLabelInputs = () => Object.keys(baseLabelInputDetails); -const otherValueToText = (otherValue) => { - const words = otherValue.replace("_", " ").split(" "); +/** @description replace all underscores with spaces, and capitalizes the first letter of each word */ +export const labelKeyToReadable = (otherValue: string) => { + const words = otherValue.replace(/_/g, " ").trim().split(" "); if (words.length == 0) return ""; return words.map((word) => word[0].toUpperCase() + word.slice(1) ).join(" "); } -const otherTextToValue = (otherText) => - otherText.toLowerCase().replace(" ", "_"); +/** @description replaces all spaces with underscores, and lowercases the string */ +export const readableLabelToKey = (otherText: string) => + otherText.trim().replace(/ /g, "_").toLowerCase(); export const getFakeEntry = (otherValue) => ({ - text: otherValueToText(otherValue), + text: labelKeyToReadable(otherValue), value: otherValue, }); diff --git a/www/js/survey/multilabel/multi-label-ui.js b/www/js/survey/multilabel/multi-label-ui.js index 0db17b44b..da77586c0 100644 --- a/www/js/survey/multilabel/multi-label-ui.js +++ b/www/js/survey/multilabel/multi-label-ui.js @@ -165,7 +165,7 @@ 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 (mls.ui_config.intro.program_or_study == 'program') { + if (mls.ui_config.intro.mode_studied) { if (inputValue == mls.ui_config.intro.mode_studied) { Logger.log("Found "+mls.ui_config.intro.mode_studied+" mode in a program, displaying full details"); trip.inputDetails = getLabelInputDetails(); diff --git a/www/js/useAppStateChange.ts b/www/js/useAppStateChange.ts new file mode 100644 index 000000000..8b9c6497c --- /dev/null +++ b/www/js/useAppStateChange.ts @@ -0,0 +1,29 @@ +//this is a custom hook that listens for change in app state +//detects when the app changed to active from inactive (ios) or background (android) +//the executes "onResume" function that is passed in +//https://reactnative.dev/docs/appstate based on react's example of detecting becoming active + +import { useEffect, useRef } from 'react'; +import { AppState } from 'react-native'; + +const useAppStateChange = (onResume) => { + + const appState = useRef(AppState.currentState); + + useEffect(() => { + const subscription = AppState.addEventListener('change', nextAppState => { + if ( appState.current != 'active' && nextAppState === 'active') { + onResume(); + } + + appState.current = nextAppState; + console.log('AppState', appState.current); + }); + + }, []); + + return {}; + } + + export default useAppStateChange; + \ No newline at end of file diff --git a/www/json/trip_confirm_options.json.sample b/www/json/trip_confirm_options.json.sample index a8bc88a0d..1e90bc1bb 100644 --- a/www/json/trip_confirm_options.json.sample +++ b/www/json/trip_confirm_options.json.sample @@ -1,23 +1,23 @@ { "MODE" : [ - {"text":"Walk", "value":"walk", "met_equivalent": "WALKING", "co2PerMeter": 0}, - {"text":"E-bike","value":"e-bike", "met": { + {"text":"Walk", "value":"walk", "baseMode":"WALKING", "met_equivalent": "WALKING", "kgCo2PerKm": 0}, + {"text":"E-bike","value":"e-bike", "baseMode": "E_BIKE", "met": { "ALL": {"range": [0, -1], "mets": 4.9} - }, "co2PerMeter": 0.00728}, - {"text":"Regular Bike","value":"bike", "met_equivalent": "BICYCLING", "co2PerMeter": 0}, - {"text":"Bikeshare","value":"bikeshare", "met_equivalent": "BICYCLING", "co2PerMeter": 0}, - {"text":"Scooter share","value":"scootershare", "met_equivalent": "IN_VEHICLE", "co2PerMeter": 0.00894}, - {"text":"Gas Car Drove Alone","value":"drove_alone", "met_equivalent": "IN_VEHICLE", "co2PerMeter": 0.22031}, - {"text":"Gas Car Shared Ride","value":"shared_ride", "met_equivalent": "IN_VEHICLE", "co2PerMeter": 0.11015}, - {"text":"E-Car Drove Alone","value":"e_car_drove_alone", "met_equivalent": "IN_VEHICLE", "co2PerMeter": 0.08216}, - {"text":"E-Car Shared Ride","value":"e_car_shared_ride", "met_equivalent": "IN_VEHICLE", "co2PerMeter": 0.04108}, - {"text":"Taxi/Uber/Lyft","value":"taxi", "met_equivalent": "IN_VEHICLE", "co2PerMeter": 0.30741}, - {"text":"Bus","value":"bus", "met_equivalent": "IN_VEHICLE", "co2PerMeter": 0.20727}, - {"text":"Train","value":"train", "met_equivalent": "IN_VEHICLE", "co2PerMeter": 0.12256}, - {"text":"Free Shuttle","value":"free_shuttle", "met_equivalent": "IN_VEHICLE", "co2PerMeter": 0.20727}, - {"text":"Air","value":"air", "met_equivalent": "IN_VEHICLE", "co2PerMeter": 0.09975}, - {"text":"Not a Trip","value":"not_a_trip", "met_equivalent": "UNKNOWN", "co2PerMeter": 0}, - {"text":"Other","value":"other", "met_equivalent": "UNKNOWN", "co2PerMeter": 0}], + }, "kgCo2PerKm": 0.00728}, + {"text":"Regular Bike","value":"bike", "baseMode":"BICYCLING", "met_equivalent": "BICYCLING", "kgCo2PerKm": 0}, + {"text":"Bikeshare","value":"bikeshare", "baseMode":"BICYCLING", "met_equivalent": "BICYCLING", "kgCo2PerKm": 0}, + {"text":"Scooter share","value":"scootershare", "baseMode":"E_SCOOTER", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.00894}, + {"text":"Gas Car Drove Alone","value":"drove_alone", "baseMode":"CAR", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.22031}, + {"text":"Gas Car Shared Ride","value":"shared_ride", "baseMode":"CAR", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.11015}, + {"text":"E-Car Drove Alone","value":"e_car_drove_alone", "baseMode":"E_CAR", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.08216}, + {"text":"E-Car Shared Ride","value":"e_car_shared_ride", "baseMode":"E_CAR", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.04108}, + {"text":"Taxi/Uber/Lyft","value":"taxi", "baseMode":"TAXI", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.30741}, + {"text":"Bus","value":"bus", "baseMode":"BUS", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.20727}, + {"text":"Train","value":"train", "baseMode":"TRAIN", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.12256}, + {"text":"Free Shuttle","value":"free_shuttle", "baseMode":"BUS", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.20727}, + {"text":"Air","value":"air", "baseMode":"AIR", "met_equivalent": "IN_VEHICLE", "kgCo2PerKm": 0.09975}, + {"text":"Not a Trip","value":"not_a_trip", "baseMode":"UNKNOWN", "met_equivalent": "UNKNOWN", "kgCo2PerKm": 0}, + {"text":"Other","value":"other", "baseMode":"OTHER", "met_equivalent": "UNKNOWN", "kgCo2PerKm": 0}], "REPLACED_MODE" : [ {"text":"No travel", "value":"no_travel"}, {"text":"Walk", "value":"walk"}, diff --git a/www/templates/control/main-control.html b/www/templates/control/main-control.html deleted file mode 100644 index 665eaf7e3..000000000 --- a/www/templates/control/main-control.html +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/www/templates/diary/mode-popover.html b/www/templates/diary/mode-popover.html deleted file mode 100644 index 7889a21c6..000000000 --- a/www/templates/diary/mode-popover.html +++ /dev/null @@ -1,10 +0,0 @@ - - -

{{'diary.select-mode-scroll'}}

-
- - - {{mode.text}} - - -
diff --git a/www/templates/diary/purpose-popover.html b/www/templates/diary/purpose-popover.html deleted file mode 100644 index 0942a9ba1..000000000 --- a/www/templates/diary/purpose-popover.html +++ /dev/null @@ -1,10 +0,0 @@ - - -

{{'diary.select-purpose-scroll'}}

-
- - - {{purpose.text}} - - -
diff --git a/www/templates/diary/replaced_mode-popover.html b/www/templates/diary/replaced_mode-popover.html deleted file mode 100644 index 9ea6dab2e..000000000 --- a/www/templates/diary/replaced_mode-popover.html +++ /dev/null @@ -1,10 +0,0 @@ - - -

{{'diary.select-replaced-mode-scroll'}}

-
- - - {{rmode.text}} - - -
diff --git a/www/templates/main-metrics.html b/www/templates/main-metrics.html index 40e731d91..d3c8b7ce3 100644 --- a/www/templates/main-metrics.html +++ b/www/templates/main-metrics.html @@ -100,7 +100,7 @@
{{'main-metrics.how-it-compares'}}

{{'main-metrics.calories'}}

-
{{'main-metrics.calibrate'}}
+
kcal
diff --git a/www/templates/survey/enketo/add-note-button.html b/www/templates/survey/enketo/add-note-button.html deleted file mode 100644 index a20703db9..000000000 --- a/www/templates/survey/enketo/add-note-button.html +++ /dev/null @@ -1,6 +0,0 @@ -
- - -
diff --git a/www/templates/survey/enketo/delete-entry.html b/www/templates/survey/enketo/delete-entry.html deleted file mode 100644 index 8bf4d62ff..000000000 --- a/www/templates/survey/enketo/delete-entry.html +++ /dev/null @@ -1,4 +0,0 @@ -
-

Are you sure you wish to delete this entry?

-

{{currEntry.data.label}}
{{currEntry.displayTime || setDisplayTime(entry)}}

-
\ No newline at end of file diff --git a/www/templates/survey/enketo/summary-trip-button.html b/www/templates/survey/enketo/summary-trip-button.html deleted file mode 100644 index 9ddfff74a..000000000 --- a/www/templates/survey/enketo/summary-trip-button.html +++ /dev/null @@ -1,27 +0,0 @@ - -
-
- {{'diary.survey'}} -
-
- -
-
- -
- - -
- -
-
diff --git a/www/templates/survey/wrapper.html b/www/templates/survey/wrapper.html deleted file mode 100644 index af5cfc3f8..000000000 --- a/www/templates/survey/wrapper.html +++ /dev/null @@ -1,2 +0,0 @@ - -