diff --git a/package.cordovabuild.json b/package.cordovabuild.json index 20672bad0..943f06520 100644 --- a/package.cordovabuild.json +++ b/package.cordovabuild.json @@ -109,7 +109,6 @@ "angular": "1.6.7", "angular-animate": "1.6.7", "angular-local-storage": "^0.7.1", - "angular-nvd3": "^1.0.7", "angular-sanitize": "1.6.7", "angular-simple-logger": "^0.1.7", "angular-translate": "^2.18.1", @@ -158,7 +157,6 @@ "moment-timezone": "^0.5.43", "ng-i18next": "^1.0.7", "npm": "^9.6.3", - "nvd3": "^1.8.6", "phonegap-plugin-barcodescanner": "git+https://github.com/phonegap/phonegap-plugin-barcodescanner#v8.1.0", "prop-types": "^15.8.1", "react": "^18.2.*", diff --git a/package.serve.json b/package.serve.json index f16c8bd66..a4e3194f3 100644 --- a/package.serve.json +++ b/package.serve.json @@ -55,7 +55,6 @@ "angular": "1.6.7", "angular-animate": "1.6.7", "angular-local-storage": "^0.7.1", - "angular-nvd3": "^1.0.7", "angular-sanitize": "1.6.7", "angular-simple-logger": "^0.1.7", "angular-translate": "^2.18.1", @@ -83,7 +82,6 @@ "moment-timezone": "^0.5.43", "ng-i18next": "^1.0.7", "npm": "^9.6.3", - "nvd3": "^1.8.6", "prop-types": "^15.8.1", "react": "^18.2.*", "react-chartjs-2": "^5.2.0", diff --git a/www/css/style.css b/www/css/style.css index 5e923f5bd..8910b2258 100644 --- a/www/css/style.css +++ b/www/css/style.css @@ -18,9 +18,6 @@ max-height: 50px; } -/* nvd3 styles */ -@import 'nvd3/build/nv.d3.css'; - .fill-container { display: block; position: relative; @@ -746,15 +743,6 @@ timestamp-badge[light-bg] { padding: 5% 10%; } -svg { - display: block; -} -#chart, #chart svg { - margin-right: 10px; -} -.nvd3, nv-noData { - font-weight: 300 !important; -} .metric-datepicker { /*height: 33px;*/ display: flex; /* establish flex container */ diff --git a/www/i18n/en.json b/www/i18n/en.json index 758960ade..fe0df617a 100644 --- a/www/i18n/en.json +++ b/www/i18n/en.json @@ -36,7 +36,9 @@ "nuke-all": "Nuke all buffers and cache", "test-notification": "Test local notification", "check-log": "Check log", + "log-title" : "Log", "check-sensed-data": "Check sensed data", + "sensed-title": "Sensed Data: Transitions", "collection": "Collection", "sync": "Sync", "button-accept": "I accept", @@ -101,9 +103,11 @@ "less-than": " less than ", "less": " less ", "week-before": "vs. week before", + "this-week": "this week", "pick-a-date": "Pick a date", "trips": "trips", "hours": "hours", + "minutes": "minutes", "custom": "Custom" }, @@ -140,42 +144,43 @@ "no-travel-hint": "To see more, change the filters above or go record some travel!" }, - "user-gender": "Gender", - "gender-male": "Male", - "gender-female": "Female", - "user-height": "Height", - "user-weight": "Weight", - "user-age": "Age", - "main-metrics":{ "dashboard": "Dashboard", "summary": "My Summary", "chart": "Chart", "change-data": "Change dates:", - "distance": "My Distance", - "trips": "My Trips", - "duration": "My Duration", + "distance": "Distance", + "trips": "Trips", + "duration": "Duration", "fav-mode": "My Favorite Mode", "speed": "My Speed", "footprint": "My Footprint", + "estimated-emissions": "Estimated CO₂ emissions", "how-it-compares": "Ballpark comparisons", - "optimal": "Optimal (perfect mode choice for all my trips):", - "average": "Average for group:", - "avoided": "CO₂ avoided (vs. all 'taxi'):", + "optimal": "Optimal (perfect mode choice for all my trips)", + "average": "Group Avg.", + "worst-case": "Worse Case", "label-to-squish": "Label trips to collapse the range into a single number", + "range-uncertain-footnote": "²Due to the uncertainty of unlabeled trips, estimates may fall anywhere within the shown range. Label more trips for richer estimates.", "lastweek": "My last week value:", - "us-2030-goal": "US 2030 Goal Estimate:", - "us-2050-goal": "US 2050 Goal Estimate:", - "calories": "My Calories", - "calibrate": "Calibrate", + "us-2030-goal": "2030 Guideline¹", + "us-2050-goal": "2050 Guideline¹", + "us-goals-footnote": "¹Guidelines based on US decarbonization goals, scaled to per-capita travel-related emissions.", + "past-week" : "Past Week", + "prev-week" : "Prev. Week", "no-summary-data": "No summary data", "mean-speed": "My Average Speed", - "equals-cookies_one": "Equals at least {{count}} homemade chocolate chip cookie", - "equals-cookies_other": "Equals at least {{count}} homemade chocolate chip cookies", - "equals-icecream_one": "Equals at least {{count}} half cup vanilla ice cream", - "equals-icecream_other": "Equals at least {{count}} half cups vanilla ice cream", - "equals-bananas_one": "Equals at least {{count}} banana", - "equals-bananas_other": "Equals at least {{count}} bananas" + "user-totals": "My Totals", + "group-totals": "Group Totals", + "active-minutes": "Active Minutes", + "weekly-active-minutes": "Weekly minutes of active travel", + "daily-active-minutes": "Daily minutes of active travel", + "active-minutes-table": "Table of active minutes metrics", + "weekly-goal": "Weekly Goal³", + "weekly-goal-footnote": "³Weekly goal based on CDC recommendation of 150 minutes of moderate activity per week.", + "labeled": "Labeled", + "unlabeled": "Unlabeled²", + "footprint-label": "Footprint (kg CO₂)" }, "main-inf-scroll" : { @@ -360,7 +365,9 @@ "errors": { "while-populating-composite": "Error while populating composite trips", "while-loading-another-week": "Error while loading travel of {{when}} week", - "while-loading-specific-week": "Error while loading travel for the week of {{day}}" + "while-loading-specific-week": "Error while loading travel for the week of {{day}}", + "while-log-messages": "While getting messages from the log ", + "while-max-index" : "While getting max index " }, "consent-text": { "title":"NREL OPENPATH PRIVACY POLICY/TERMS OF USE", diff --git a/www/index.js b/www/index.js index 66a0d45df..17a5326d7 100644 --- a/www/index.js +++ b/www/index.js @@ -30,7 +30,6 @@ import './js/survey/input-matcher.js'; import './js/survey/multilabel/infinite_scroll_filters.js'; import './js/survey/multilabel/multi-label-ui.js'; import './js/diary.js'; -import './js/recent.js'; import './js/diary/services.js'; import './js/survey/external/launch.js'; import './js/survey/enketo/answer.js'; @@ -40,7 +39,6 @@ import './js/survey/enketo/infinite_scroll_filters.js'; import './js/survey/enketo/enketo-trip-button.js'; import './js/survey/enketo/enketo-demographics.js'; import './js/survey/enketo/enketo-add-note-button.js'; -import './js/metrics.js'; import './js/control/general-settings.js'; import './js/control/emailService.js'; import './js/control/uploadService.js'; diff --git a/www/js/appTheme.ts b/www/js/appTheme.ts index 7571a564c..5f47f00b1 100644 --- a/www/js/appTheme.ts +++ b/www/js/appTheme.ts @@ -26,7 +26,8 @@ const AppTheme = { level4: '#e0f0ff', // lch(94% 50 250) level5: '#d6ebff', // lch(92% 50 250) }, - success: '#38872e', // lch(50% 55 135) + success: '#00a665', // lch(60% 55 155) + warn: '#f8cf53', //lch(85% 65 85) danger: '#f23934' // lch(55% 85 35) }, roundness: 5, diff --git a/www/js/components/ActionMenu.tsx b/www/js/components/ActionMenu.tsx new file mode 100644 index 000000000..296717a00 --- /dev/null +++ b/www/js/components/ActionMenu.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { Modal } from "react-native"; +import { Dialog, Button, useTheme } from "react-native-paper"; +import { useTranslation } from "react-i18next"; +import { settingStyles } from "../control/ProfileSettings"; + +const ActionMenu = ({vis, setVis, title, actionSet, onAction, onExit}) => { + + const { t } = useTranslation(); + const { colors } = useTheme(); + + return ( + setVis(false)} + transparent={true}> + setVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + {title} + + {actionSet?.map((e) => + + )} + + + + + + + ) +} + +export default ActionMenu; \ No newline at end of file diff --git a/www/js/components/BarChart.tsx b/www/js/components/BarChart.tsx index 6da3d2a2b..1e957923b 100644 --- a/www/js/components/BarChart.tsx +++ b/www/js/components/BarChart.tsx @@ -1,201 +1,27 @@ +import React from "react"; +import Chart, { Props as ChartProps } from "./Chart"; +import { useTheme } from "react-native-paper"; +import { getGradient } from "./charting"; -import React, { useRef, useState } from 'react'; -import { array, string, bool } from 'prop-types'; -import { angularize } from '../angular-react-helper'; -import { View } from 'react-native'; -import { useTheme } from 'react-native-paper'; -import { Chart, CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend, TimeScale } from 'chart.js'; -import { Bar } from 'react-chartjs-2'; -import Annotation, { AnnotationOptions } from 'chartjs-plugin-annotation'; - -Chart.register( - CategoryScale, - LinearScale, - TimeScale, - BarElement, - Title, - Tooltip, - Legend, - Annotation, -); - -const BarChart = ({ chartData, axisTitle, lineAnnotations=null, isHorizontal=false }) => { +type Props = Omit & { + meter?: {high: number, middle: number, dash_key: string}, +} +const BarChart = ({ meter, ...rest }: Props) => { const { colors } = useTheme(); - const [ numVisibleDatasets, setNumVisibleDatasets ] = useState(1); - - const barChartRef = useRef(null); - - const defaultPalette = [ - '#c95465', // red oklch(60% 0.15 14) - '#4a71b1', // blue oklch(55% 0.11 260) - '#d2824e', // orange oklch(68% 0.12 52) - '#856b5d', // brown oklch(55% 0.04 50) - '#59894f', // green oklch(58% 0.1 140) - '#e0cc55', // yellow oklch(84% 0.14 100) - '#b273ac', // purple oklch(64% 0.11 330) - '#f09da6', // pink oklch(78% 0.1 12) - '#b3aca8', // grey oklch(75% 0.01 55) - '#80afad', // teal oklch(72% 0.05 192) - ] - - const indexAxis = isHorizontal ? 'y' : 'x'; - function getChartHeight() { - /* when horizontal charts have more data, they should get taller - so they don't look squished */ - if (isHorizontal) { - // 'ideal' chart height is based on the number of datasets and number of unique index values - const uniqueIndexVals = []; - chartData.forEach(e => e.records.forEach(r => { - if (!uniqueIndexVals.includes(r[indexAxis])) uniqueIndexVals.push(r[indexAxis]); - })); - const numIndexVals = uniqueIndexVals.length; - const idealChartHeight = numVisibleDatasets * numIndexVals * 8; - - /* each index val should be at least 20px tall for visibility, - and the graph itself should be at least 250px tall */ - const minChartHeight = Math.max(numIndexVals * 20, 250); - - // return whichever is greater - return { height: Math.max(idealChartHeight, minChartHeight) }; + if (meter) { + rest.getColorForChartEl = (chart, dataset, ctx, colorFor) => { + const darkenDegree = colorFor == 'border' ? 0.25 : 0; + const alpha = colorFor == 'border' ? 1 : 0; + return getGradient(chart, meter, dataset, ctx, alpha, darkenDegree); } - // vertical charts will just match the parent container - return { height: '100%' }; + rest.borderWidth = 3; } return ( - - ({ - label: d.label, - data: d.records, - // cycle through the default palette, repeat if necessary - backgroundColor: defaultPalette[i % defaultPalette.length], - })) - }} - options={{ - indexAxis: indexAxis, - responsive: true, - maintainAspectRatio: false, - resizeDelay: 1, - scales: { - ...(isHorizontal ? { - y: { - offset: true, - type: 'time', - adapters: { - date: { zone: 'utc' }, - }, - time: { - unit: 'day', - tooltipFormat: 'DDD', // Luxon "localized date with full month": e.g. August 6, 2014 - }, - beforeUpdate: (axis) => { - setNumVisibleDatasets(axis.chart.getVisibleDatasetCount()) - }, - reverse: true, - }, - x: { - title: { display: true, text: axisTitle }, - }, - } : { - x: { - offset: true, - type: 'time', - adapters: { - date: { zone: 'utc' }, - }, - time: { - unit: 'day', - tooltipFormat: 'DDD', // Luxon "localized date with full month": e.g. August 6, 2014 - }, - }, - y: { - title: { display: true, text: axisTitle }, - }, - }), - }, - plugins: { - ...(lineAnnotations?.length > 0 && { - annotation: { - annotations: lineAnnotations.map((a, i) => ({ - type: 'line', - label: { - display: true, - padding: { x: 3, y: 1 }, - borderRadius: 0, - backgroundColor: 'rgba(0,0,0,.7)', - color: 'rgba(255,255,255,1)', - font: { size: 10 }, - position: 'start', - content: a.label, - }, - ...(isHorizontal ? { xMin: a.value, xMax: a.value } - : { yMin: a.value, yMax: a.value }), - borderColor: colors.onBackground, - borderWidth: 2, - borderDash: [3, 3], - } satisfies AnnotationOptions)), - } - }), - } - }} /> - - ) + + ); } -BarChart.propTypes = { - chartData: array, - axisTitle: string, - lineAnnotations: array, - isHorizontal: bool, -}; - -angularize(BarChart, 'BarChart', 'emission.main.barchart'); export default BarChart; - -// const sampleAnnotations = [ -// { value: 35, label: 'Target1' }, -// { value: 65, label: 'Target2' }, -// ]; - -// const sampleChartData = [ -// { -// label: 'Primary', -// records: [ -// { x: moment('2023-06-20'), y: 20 }, -// { x: moment('2023-06-21'), y: 30 }, -// { x: moment('2023-06-23'), y: 80 }, -// { x: moment('2023-06-24'), y: 40 }, -// ], -// }, -// { -// label: 'Secondary', -// records: [ -// { x: moment('2023-06-21'), y: 10 }, -// { x: moment('2023-06-22'), y: 50 }, -// { x: moment('2023-06-23'), y: 30 }, -// { x: moment('2023-06-25'), y: 40 }, -// ], -// }, -// { -// label: 'Tertiary', -// records: [ -// { x: moment('2023-06-20'), y: 30 }, -// { x: moment('2023-06-22'), y: 40 }, -// { x: moment('2023-06-24'), y: 10 }, -// { x: moment('2023-06-25'), y: 60 }, -// ], -// }, -// { -// label: 'Quaternary', -// records: [ -// { x: moment('2023-06-22'), y: 10 }, -// { x: moment('2023-06-23'), y: 20 }, -// { x: moment('2023-06-24'), y: 30 }, -// { x: moment('2023-06-25'), y: 40 }, -// ], -// }, -// ]; diff --git a/www/js/components/Carousel.tsx b/www/js/components/Carousel.tsx new file mode 100644 index 000000000..28a31ff6a --- /dev/null +++ b/www/js/components/Carousel.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { ScrollView, View } from 'react-native'; + +type Props = { + children: React.ReactNode, + cardWidth: number, + cardMargin: number, +} +const Carousel = ({ children, cardWidth, cardMargin }: Props) => { + const numCards = React.Children.count(children); + return ( + + {React.Children.map(children, (child, i) => ( + + {child} + + ))} + + ) +}; + +export const s = { + carouselScroll: (cardMargin) => ({ + // @ts-ignore, RN doesn't recognize `scrollSnapType`, but it does work on RN Web + scrollSnapType: 'x mandatory', + paddingVertical: 10, + }), + carouselCard: (cardWidth, cardMargin, isFirst, isLast) => ({ + marginLeft: isFirst ? cardMargin : cardMargin/2, + marginRight: isLast ? cardMargin : cardMargin/2, + width: cardWidth, + scrollSnapAlign: 'center', + scrollSnapStop: 'always', + }), +}; + +export default Carousel; diff --git a/www/js/components/Chart.tsx b/www/js/components/Chart.tsx new file mode 100644 index 000000000..79c6e40e4 --- /dev/null +++ b/www/js/components/Chart.tsx @@ -0,0 +1,196 @@ + +import React, { useEffect, useRef, useState, useMemo } from 'react'; +import { View } from 'react-native'; +import { useTheme } from 'react-native-paper'; +import { Chart as ChartJS, registerables } from 'chart.js'; +import { Chart as ChartJSChart } from 'react-chartjs-2'; +import Annotation, { AnnotationOptions, LabelPosition } from 'chartjs-plugin-annotation'; +import { dedupColors, getChartHeight, darkenOrLighten } from './charting'; + +ChartJS.register(...registerables, Annotation); + +type XYPair = { x: number|string, y: number|string }; +type ChartDataset = { + label: string, + data: XYPair[], +}; + +export type Props = { + records: { label: string, x: number|string, y: number|string }[], + axisTitle: string, + type: 'bar'|'line', + getColorForLabel?: (label: string) => string, + getColorForChartEl?: (chart, currDataset: ChartDataset, ctx: ScriptableContext<'bar'|'line'>, colorFor: 'background'|'border') => string|CanvasGradient|null, + borderWidth?: number, + lineAnnotations?: { value: number, label?: string, color?:string, position?: LabelPosition }[], + isHorizontal?: boolean, + timeAxis?: boolean, + stacked?: boolean, +} +const Chart = ({ records, axisTitle, type, getColorForLabel, getColorForChartEl, borderWidth, lineAnnotations, isHorizontal, timeAxis, stacked }: Props) => { + + const { colors } = useTheme(); + const [ numVisibleDatasets, setNumVisibleDatasets ] = useState(1); + + const indexAxis = isHorizontal ? 'y' : 'x'; + const chartRef = useRef>(null); + const [chartDatasets, setChartDatasets] = useState([]); + + const chartData = useMemo>(() => { + let labelColorMap; // object mapping labels to colors + if (getColorForLabel) { + const colorEntries = chartDatasets.map(d => [d.label, getColorForLabel(d.label)] ); + labelColorMap = dedupColors(colorEntries); + } + return { + datasets: chartDatasets.map((e, i) => ({ + ...e, + backgroundColor: (barCtx) => ( + labelColorMap?.[e.label] || getColorForChartEl(chartRef.current, e, barCtx, 'background') + ), + borderColor: (barCtx) => ( + darkenOrLighten(labelColorMap?.[e.label], -.5) || getColorForChartEl(chartRef.current, e, barCtx, 'border') + ), + borderWidth: borderWidth || 2, + borderRadius: 3, + })), + }; + }, [chartDatasets, getColorForLabel]); + + // group records by label (this is the format that Chart.js expects) + useEffect(() => { + const d = records?.reduce((acc, record) => { + const existing = acc.find(e => e.label == record.label); + if (!existing) { + acc.push({ + label: record.label, + data: [{ + x: record.x, + y: record.y, + }], + }); + } else { + existing.data.push({ + x: record.x, + y: record.y, + }); + } + return acc; + }, [] as ChartDataset[]); + setChartDatasets(d); + }, [records]); + + const annotationsAtTop = isHorizontal && lineAnnotations?.some(a => (!a.position || a.position == 'start')); + + return ( + + { + setNumVisibleDatasets(axis.chart.getVisibleDatasetCount()) + }, + ticks: timeAxis ? {} : { + callback: (value, i) => { + const label = chartDatasets[0].data[i].y; + if (typeof label == 'string' && label.includes('\n')) + return label.split('\n'); + return label; + }, + font: { size: 11 }, // default is 12, we want a tad smaller + }, + reverse: true, + stacked, + }, + x: { + title: { display: true, text: axisTitle }, + stacked, + }, + } : { + x: { + offset: true, + type: timeAxis ? 'time' : 'category', + adapters: timeAxis ? { + date: { zone: 'utc' }, + } : {}, + time: timeAxis ? { + unit: 'day', + tooltipFormat: 'DDD', // Luxon "localized date with full month": e.g. August 6, 2014 + } : {}, + ticks: timeAxis ? {} : { + callback: (value, i) => { + console.log("testing vertical", chartData, i); + const label = chartDatasets[0].data[i].x; + if (typeof label == 'string' && label.includes('\n')) + return label.split('\n'); + return label; + }, + }, + stacked, + }, + y: { + title: { display: true, text: axisTitle }, + stacked, + }, + }), + }, + plugins: { + ...(lineAnnotations?.length > 0 && { + annotation: { + clip: false, + annotations: lineAnnotations.map((a, i) => ({ + type: 'line', + label: { + display: true, + padding: { x: 3, y: 1 }, + borderRadius: 0, + backgroundColor: 'rgba(0,0,0,.7)', + color: 'rgba(255,255,255,1)', + font: { size: 10 }, + position: a.position || 'start', + content: a.label, + yAdjust: annotationsAtTop ? -12 : 0, + }, + ...(isHorizontal ? { xMin: a.value, xMax: a.value } + : { yMin: a.value, yMax: a.value }), + borderColor: a.color || colors.onBackground, + borderWidth: 3, + borderDash: [3, 3], + } satisfies AnnotationOptions)), + } + }), + } + }} + // if there are annotations at the top of the chart, it overlaps with the legend + // so we need to increase the spacing between the legend and the chart + // https://stackoverflow.com/a/73498454 + plugins={annotationsAtTop && [{ + id: "increase-legend-spacing", + beforeInit(chart) { + const originalFit = (chart.legend as any).fit; + (chart.legend as any).fit = function fit() { + originalFit.bind(chart.legend)(); + this.height += 12; + }; + } + }]} /> + + ) +} +export default Chart; diff --git a/www/js/components/LineChart.tsx b/www/js/components/LineChart.tsx new file mode 100644 index 000000000..66d21aac2 --- /dev/null +++ b/www/js/components/LineChart.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import Chart, { Props as ChartProps } from "./Chart"; + +type Props = Omit & { } +const LineChart = ({ ...rest }: Props) => { + return ( + + ); +} + +export default LineChart; diff --git a/www/js/components/charting.ts b/www/js/components/charting.ts new file mode 100644 index 000000000..f0da14619 --- /dev/null +++ b/www/js/components/charting.ts @@ -0,0 +1,161 @@ +import color from 'color'; +import { getBaseModeByKey } from '../diary/diaryHelper'; +import { readableLabelToKey } from '../survey/multilabel/confirmHelper'; + +export const defaultPalette = [ + '#c95465', // red oklch(60% 0.15 14) + '#4a71b1', // blue oklch(55% 0.11 260) + '#d2824e', // orange oklch(68% 0.12 52) + '#856b5d', // brown oklch(55% 0.04 50) + '#59894f', // green oklch(58% 0.1 140) + '#e0cc55', // yellow oklch(84% 0.14 100) + '#b273ac', // purple oklch(64% 0.11 330) + '#f09da6', // pink oklch(78% 0.1 12) + '#b3aca8', // grey oklch(75% 0.01 55) + '#80afad', // teal oklch(72% 0.05 192) +]; + +export function getChartHeight(chartDatasets, numVisibleDatasets, indexAxis, isHorizontal, stacked) { + /* when horizontal charts have more data, they should get taller + so they don't look squished */ + if (isHorizontal) { + // 'ideal' chart height is based on the number of datasets and number of unique index values + const uniqueIndexVals = []; + chartDatasets.forEach(e => e.data.forEach(r => { + if (!uniqueIndexVals.includes(r[indexAxis])) uniqueIndexVals.push(r[indexAxis]); + })); + const numIndexVals = uniqueIndexVals.length; + const heightPerIndexVal = stacked ? 36 : numVisibleDatasets * 8; + const idealChartHeight = heightPerIndexVal * numIndexVals; + + /* each index val should be at least 20px tall for visibility, + and the graph itself should be at least 250px tall */ + const minChartHeight = Math.max(numIndexVals * 20, 250); + + // return whichever is greater + return { height: Math.max(idealChartHeight, minChartHeight) }; + } + // vertical charts should just fill the available space in the parent container + return { flex: 1 }; +} + +function getBarHeight(stacks) { + let totalHeight = 0; + console.log("ctx stacks", stacks.x); + for(let val in stacks.x) { + if(!val.startsWith('_')){ + totalHeight += stacks.x[val]; + console.log("ctx added ", val ); + } + } + return totalHeight; +} + +//fill pattern creation +//https://stackoverflow.com/questions/28569667/fill-chart-js-bar-chart-with-diagonal-stripes-or-other-patterns +function createDiagonalPattern(color = 'black') { + let shape = document.createElement('canvas') + shape.width = 10 + shape.height = 10 + let c = shape.getContext('2d') + c.strokeStyle = color + c.lineWidth = 2 + c.beginPath() + c.moveTo(2, 0) + c.lineTo(10, 8) + c.stroke() + c.beginPath() + c.moveTo(0, 8) + c.lineTo(2, 10) + c.stroke() + return c.createPattern(shape, 'repeat') +} + +export function getMeteredBackgroundColor(meter, currDataset, barCtx, colors, darken=0) { + if (!barCtx || !currDataset) return; + let bar_height = getBarHeight(barCtx.parsed._stacks); + console.debug("bar height for", barCtx.raw.y, " is ", bar_height, "which in chart is", currDataset); + let meteredColor; + if (bar_height > meter.high) meteredColor = colors.danger; + else if (bar_height > meter.middle) meteredColor = colors.warn; + else meteredColor = colors.success; + if (darken) { + return color(meteredColor).darken(darken).hex(); + } + //if "unlabeled", etc -> stripes + if (currDataset.label == meter.dash_key) { + return createDiagonalPattern(meteredColor); + } + //if :labeled", etc -> solid + return meteredColor; +} + +const meterColors = { + below: '#00cc95', // green oklch(75% 0.3 165) + // https://www.joshwcomeau.com/gradient-generator?colors=fcab00|ba0000&angle=90&colorMode=lab&precision=3&easingCurve=0.25|0.75|0.75|0.25 + between: ['#fcab00', '#ef8215', '#db5e0c', '#ce3d03', '#b70100'], // yellow-orange-red + above: '#440000', // dark red +} + +export function getGradient(chart, meter, currDataset, barCtx, alpha = null, darken = 0) { + const { ctx, chartArea, scales } = chart; + if (!chartArea) return null; + let gradient: CanvasGradient; + const total = getBarHeight(barCtx.parsed._stacks); + alpha = alpha || (currDataset.label == meter.dash_key ? 0.2 : 1); + if (total < meter.middle) { + const adjColor = darken||alpha ? color(meterColors.below).darken(darken).alpha(alpha).rgb().string() : meterColors.below; + return adjColor; + } + const scaleMaxX = scales.x._range.max; + gradient = ctx.createLinearGradient(chartArea.left, 0, chartArea.right, 0); + meterColors.between.forEach((clr, i) => { + const clrPosition = ((i + 1) / meterColors.between.length) * (meter.high - meter.middle) + meter.middle; + const adjColor = darken || alpha ? color(clr).darken(darken).alpha(alpha).rgb().string() : clr; + gradient.addColorStop(Math.min(clrPosition, scaleMaxX) / scaleMaxX, adjColor); + }); + if (scaleMaxX > meter.high + 20) { + const adjColor = darken||alpha ? color(meterColors.above).darken(0.2).alpha(alpha).rgb().string() : meterColors.above; + gradient.addColorStop((meter.high+20) / scaleMaxX, adjColor); + } + return gradient; +} + +/** + * @param baseColor a color string + * @param change a number between -1 and 1, indicating the amount to darken or lighten the color + * @returns an adjusted color, either darkened or lightened, depending on the sign of change + */ +export function darkenOrLighten(baseColor: string, change: number) { + if (!baseColor) return baseColor; + let colorObj = color(baseColor); + if(change < 0) { + // darkening appears more drastic than lightening, so we will be less aggressive (scale change by .5) + return colorObj.darken(Math.abs(change * .5)).hex(); + } else { + return colorObj.lighten(Math.abs(change)).hex(); + } +} + +/** + * @param colors an array of colors, each of which is an array of [key, color string] + * @returns an object mapping keys to colors, with duplicates darkened/lightened to be distinguishable + */ +export const dedupColors = (colors: string[][]) => { + const dedupedColors = {}; + const maxAdjustment = 0.7; // more than this is too drastic and the colors approach black/white + for (const [key, clr] of colors) { + if (!clr) continue; // skip empty colors + const duplicates = colors.filter(([k, c]) => c == clr); + if (duplicates.length > 1) { + // there are duplicates; calculate an evenly-spaced adjustment for each one + duplicates.forEach(([k, c], i) => { + const change = -maxAdjustment + (maxAdjustment*2 / (duplicates.length - 1)) * i; + dedupedColors[k] = darkenOrLighten(clr, change); + }); + } else if (!dedupedColors[key]) { + dedupedColors[key] = clr; // not a dupe, & not already deduped, so use the color as-is + } + } + return dedupedColors; +} diff --git a/www/js/config/useImperialConfig.ts b/www/js/config/useImperialConfig.ts index 3ddb287a2..58af79551 100644 --- a/www/js/config/useImperialConfig.ts +++ b/www/js/config/useImperialConfig.ts @@ -1,33 +1,40 @@ import React, { useEffect, useState } from "react"; import useAppConfig from "../useAppConfig"; +import i18next from "i18next"; const KM_TO_MILES = 0.621371; -/* formatting distances for display: - - if distance >= 100, round to the nearest integer - e.g. "105 mi", "167 km" - - if 1 <= distance < 100, round to 3 significant digits - e.g. "7.02 mi", "11.3 km" - - if distance < 1, round to 2 significant digits - e.g. "0.47 mi", "0.75 km" */ -const formatDistance = (dist: number) => { - if (dist < 1) - return dist.toPrecision(2); - if (dist < 100) - return dist.toPrecision(3); - return Math.round(dist).toString(); +const MPS_TO_KMPH = 3.6; + +// it might make sense to move this to a more general location in the codebase +/* formatting units for display: + - if value >= 100, round to the nearest integer + e.g. "105 mi", "119 kmph" + - if 1 <= value < 100, round to 3 significant digits + e.g. "7.02 km", "11.3 mph" + - if value < 1, round to 2 decimal places + e.g. "0.07 mi", "0.75 km" */ +export const formatForDisplay = (value: number): string => { + let opts: Intl.NumberFormatOptions = {}; + if (value >= 100) + opts.maximumFractionDigits = 0; + else if (value >= 1) + opts.maximumSignificantDigits = 3; + else + opts.maximumFractionDigits = 2; + return Intl.NumberFormat(i18next.language, opts).format(value); } -const getFormattedDistanceInKm = (distInMeters: string) => - formatDistance(Number.parseFloat(distInMeters) / 1000); - -const getFormattedDistanceInMiles = (distInMeters: string) => - formatDistance((Number.parseFloat(distInMeters) / 1000) * KM_TO_MILES); - -const getKmph = (metersPerSec) => - (metersPerSec * 3.6).toFixed(2); +const convertDistance = (distMeters: number, imperial: boolean): number => { + if (imperial) + return (distMeters / 1000) * KM_TO_MILES; + return distMeters / 1000; +} -const getMph = (metersPerSecond) => - (KM_TO_MILES * Number.parseFloat(getKmph(metersPerSecond))).toFixed(2); +const convertSpeed = (speedMetersPerSec: number, imperial: boolean): number => { + if (imperial) + return speedMetersPerSec * MPS_TO_KMPH * KM_TO_MILES; + return speedMetersPerSec * MPS_TO_KMPH; +} export function useImperialConfig() { const { appConfig, loading } = useAppConfig(); @@ -41,7 +48,9 @@ export function useImperialConfig() { return { distanceSuffix: useImperial ? "mi" : "km", speedSuffix: useImperial ? "mph" : "kmph", - getFormattedDistance: useImperial ? getFormattedDistanceInMiles : getFormattedDistanceInKm, - getFormattedSpeed: useImperial ? getMph : getKmph, + getFormattedDistance: useImperial ? (d) => formatForDisplay(convertDistance(d, true)) + : (d) => formatForDisplay(convertDistance(d, false)), + getFormattedSpeed: useImperial ? (s) => formatForDisplay(convertSpeed(s, true)) + : (s) => formatForDisplay(convertSpeed(s, false)), } } diff --git a/www/js/control/AlertBar.jsx b/www/js/control/AlertBar.jsx index c56db7ad2..fbac80056 100644 --- a/www/js/control/AlertBar.jsx +++ b/www/js/control/AlertBar.jsx @@ -4,7 +4,7 @@ import { Snackbar } from 'react-native-paper'; import { useTranslation } from "react-i18next"; import { SafeAreaView } from "react-native-safe-area-context"; -const AlertBar = ({visible, setVisible, messageKey, messageAddition}) => { +const AlertBar = ({visible, setVisible, messageKey, messageAddition=undefined}) => { const { t } = useTranslation(); const onDismissSnackBar = () => setVisible(false); diff --git a/www/js/control/AppStatusModal.tsx b/www/js/control/AppStatusModal.tsx index 64c63f720..34f5820bf 100644 --- a/www/js/control/AppStatusModal.tsx +++ b/www/js/control/AppStatusModal.tsx @@ -1,5 +1,5 @@ //component to view and manage permission settings -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useMemo, useRef } from "react"; import { Modal, useWindowDimensions, ScrollView } from "react-native"; import { Dialog, Button, Text, useTheme } from 'react-native-paper'; import { useTranslation } from "react-i18next"; @@ -8,17 +8,16 @@ import useAppConfig from "../useAppConfig"; import useAppStateChange from "../useAppStateChange"; import ExplainPermissions from "../appstatus/ExplainPermissions"; import AlertBar from "./AlertBar"; +import { settingStyles } from "./ProfileSettings"; -const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) => { +const AppStatusModal = ({permitVis, setPermitVis}) => { 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 osver = useRef(0); + const platform = useRef(""); const [error, setError] = useState(""); const [errorVis, setErrorVis] = useState(false); @@ -32,10 +31,12 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) = let iconMap = (statusState) => statusState ? "check-circle-outline" : "alpha-x-circle-outline"; let colorMap = (statusState) => statusState ? colors.success : colors.danger; - const overallStatus = useMemo(() => { + const overallStatus = useMemo(() => { let status = true; + if (!checkList?.length) return undefined; // if checks not loaded yet, status is undetermined checkList.forEach((lc) => { - if(!lc.statusState){ + console.debug('check in permission status for ' + lc.name + ':', lc.statusState); + if (lc.statusState === false) { status = false; } }) @@ -97,17 +98,17 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) = return checkOrFix(locPermissionsCheck, window['cordova'].plugins.BEMDataCollection.isValidLocationPermissions, false); }; var androidSettingsDescTag = "intro.appstatus.locsettings.description.android-gte-9"; - if (osver < 9) { + if (osver.current < 9) { androidSettingsDescTag = "intro.appstatus.locsettings.description.android-lt-9"; } var androidPermDescTag = "intro.appstatus.locperms.description.android-gte-12"; - if(osver < 6) { + if(osver.current < 6) { androidPermDescTag = 'intro.appstatus.locperms.description.android-lt-6'; - } else if (osver < 10) { + } else if (osver.current < 10) { androidPermDescTag = "intro.appstatus.locperms.description.android-6-9"; - } else if (osver < 11) { + } else if (osver.current < 11) { androidPermDescTag= "intro.appstatus.locperms.description.android-10"; - } else if (osver < 12) { + } else if (osver.current < 12) { androidPermDescTag= "intro.appstatus.locperms.description.android-11"; } console.log("description tags are "+androidSettingsDescTag+" "+androidPermDescTag); @@ -115,14 +116,12 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) = 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 } @@ -154,7 +153,7 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) = }; var iOSSettingsDescTag = "intro.appstatus.locsettings.description.ios"; var iOSPermDescTag = "intro.appstatus.locperms.description.ios-gte-13"; - if(osver < 13) { + if(osver.current < 13) { iOSPermDescTag = 'intro.appstatus.locperms.description.ios-lt-13'; } console.log("description tags are "+iOSSettingsDescTag+" "+iOSPermDescTag); @@ -162,14 +161,12 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) = 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 }; @@ -179,7 +176,7 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) = } function setupAndroidFitnessChecks() { - if(osver >= 10){ + if(osver.current >= 10){ let fixPerms = function() { console.log("fix and refresh fitness permissions"); return checkOrFix(fitnessPermissionsCheck, window['cordova'].plugins.BEMDataCollection.fixFitnessPermissions, @@ -270,10 +267,10 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) = false); }; var androidUnusedDescTag = "intro.appstatus.unusedapprestrict.description.android-disable-gte-13"; - if (osver == 12) { + if (osver.current == 12) { androidUnusedDescTag= "intro.appstatus.unusedapprestrict.description.android-disable-12"; } - else if (osver < 12) { + else if (osver.current < 12) { androidUnusedDescTag= "intro.appstatus.unusedapprestrict.description.android-disable-lt-12"; } let unusedAppsUnrestrictedCheck = { @@ -298,9 +295,9 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) = let overallFitnessName = t('intro.appstatus.overall-fitness-name-android'); let locExplanation = t('intro.appstatus.overall-loc-description'); - if(platform == "ios") { + if(platform.current == "ios") { overallFitnessName = t('intro.appstatus.overall-fitness-name-ios'); - if(osver < 13) { + if(osver.current < 13) { locExplanation = (t("intro.permissions.locationPermExplanation-ios-lt-13")); } else { locExplanation = (t("intro.permissions.locationPermExplanation-ios-gte-13")); @@ -319,12 +316,13 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) = } function createChecklist(){ - if(platform == "android") { + console.debug("setting up checks, platform is " + platform.current + "and osver is " + osver.current); + if(platform.current == "android") { setupAndroidLocChecks(); setupAndroidFitnessChecks(); setupAndroidNotificationChecks(); setupAndroidBackgroundRestrictionChecks(); - } else if (platform == "ios") { + } else if (platform.current == "ios") { setupIOSLocChecks(); setupIOSFitnessChecks(); setupAndroidNotificationChecks(); @@ -369,17 +367,11 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) = 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]); + platform.current = window['device'].platform.toLowerCase(); + osver.current = window['device'].version.split(".")[0]; if(!haveSetText) { @@ -387,7 +379,7 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) = setupPermissionText(); setHaveSetText(true); } - else{ + if(!checkList || checkList.length == 0) { console.log("setting up permissions"); createChecklist(); } @@ -412,7 +404,7 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) = setPermitVis(false)} transparent={true}> setPermitVis(false)} - style={dialogStyle}> + style={settingStyles.dialog(colors.elevation.level3)}> {t('consent.permissions')} @@ -450,4 +442,4 @@ const AppStatusModal = ({permitVis, setPermitVis, dialogStyle, settingsScope}) = ) } -export default AppStatusModal; \ No newline at end of file +export default AppStatusModal; diff --git a/www/js/control/ControlCollectionHelper.tsx b/www/js/control/ControlCollectionHelper.tsx new file mode 100644 index 000000000..99318b1ac --- /dev/null +++ b/www/js/control/ControlCollectionHelper.tsx @@ -0,0 +1,285 @@ +import React, { useEffect, useState } from "react"; +import { Modal, View } from "react-native"; +import { Dialog, Button, Switch, Text, useTheme, TextInput } from 'react-native-paper'; +import { useTranslation } from "react-i18next"; +import ActionMenu from "../components/ActionMenu"; +import { settingStyles } from "./ProfileSettings"; +import { getAngularService } from "../angular-react-helper"; + +type collectionConfig = { + is_duty_cycling: boolean, + simulate_user_interaction: boolean, + accuracy: number, + accuracy_threshold: number, + filter_distance: number, + filter_time: number, + geofence_radius: number, + ios_use_visit_notifications_for_detection: boolean, + ios_use_remote_push_for_sync: boolean, + android_geofence_responsiveness: number +}; + +export async function forceTransition(transition) { + try { + let result = forceTransitionWrapper(transition); + window.alert('success -> '+result); + } catch (err) { + window.alert('error -> '+err); + console.log("error forcing state", err); + } +} + +async function accuracy2String(config) { + var accuracy = config.accuracy; + let accuracyOptions = await getAccuracyOptions(); + for (var k in accuracyOptions) { + if (accuracyOptions[k] == accuracy) { + return k; + } + } + return accuracy; +} + +export async function isMediumAccuracy() { + let config = await getConfig(); + if (!config || config == null) { + return undefined; // config not loaded when loading ui, set default as false + } else { + var v = await accuracy2String(config); + console.log("window platform is", window['cordova'].platformId); + if (window['cordova'].platformId == 'ios') { + return v != "kCLLocationAccuracyBestForNavigation" && v != "kCLLocationAccuracyBest" && v != "kCLLocationAccuracyTenMeters"; + } else if (window['cordova'].platformId == 'android') { + return v != "PRIORITY_HIGH_ACCURACY"; + } else { + window.alert("Emission does not support this platform"); + } + } +} + +export async function helperToggleLowAccuracy() { + const Logger = getAngularService("Logger"); + let tempConfig = await getConfig(); + let accuracyOptions = await getAccuracyOptions(); + let medium = await isMediumAccuracy(); + if (medium) { + if (window['cordova'].platformId == 'ios') { + tempConfig.accuracy = accuracyOptions["kCLLocationAccuracyBest"]; + } else if (window['cordova'].platformId == 'android') { + tempConfig.accuracy = accuracyOptions["PRIORITY_HIGH_ACCURACY"]; + } + } else { + if (window['cordova'].platformId == 'ios') { + tempConfig.accuracy = accuracyOptions["kCLLocationAccuracyHundredMeters"]; + } else if (window['cordova'].platformId == 'android') { + tempConfig.accuracy = accuracyOptions["PRIORITY_BALANCED_POWER_ACCURACY"]; + } + } + try{ + let set = await setConfig(tempConfig); + console.log("setConfig Sucess"); + } catch (err) { + Logger.displayError("Error while setting collection config", err); + } +} + +/* +* Simple read/write wrappers +*/ + +export const getState = function() { + return window['cordova'].plugins.BEMDataCollection.getState(); +}; + +export async function getHelperCollectionSettings() { + let promiseList = []; + promiseList.push(getConfig()); + promiseList.push(getAccuracyOptions()); + let resultList = await Promise.all(promiseList); + let tempConfig = resultList[0]; + let tempAccuracyOptions = resultList[1]; + return formatConfigForDisplay(tempConfig, tempAccuracyOptions); +} + +const setConfig = function(config) { + return window['cordova'].plugins.BEMDataCollection.setConfig(config); +}; + +const getConfig = function() { + return window['cordova'].plugins.BEMDataCollection.getConfig(); +}; +const getAccuracyOptions = function() { + return window['cordova'].plugins.BEMDataCollection.getAccuracyOptions(); +}; + +export const forceTransitionWrapper = function(transition) { + return window['cordova'].plugins.BEMDataCollection.forceTransition(transition); +}; + +const formatConfigForDisplay = function(config, accuracyOptions) { + var retVal = []; + for (var prop in config) { + if (prop == "accuracy") { + for (var name in accuracyOptions) { + if (accuracyOptions[name] == config[prop]) { + retVal.push({'key': prop, 'val': name}); + } + } + } else { + retVal.push({'key': prop, 'val': config[prop]}); + } + } + return retVal; +} + +const ControlSyncHelper = ({ editVis, setEditVis }) => { + const {colors} = useTheme(); + const Logger = getAngularService("Logger"); + + const [ localConfig, setLocalConfig ] = useState(); + const [ accuracyActions, setAccuracyActions ] = useState([]); + const [ accuracyVis, setAccuracyVis ] = useState(false); + + async function getCollectionSettings() { + let promiseList = []; + promiseList.push(getConfig()); + promiseList.push(getAccuracyOptions()); + let resultList = await Promise.all(promiseList); + let tempConfig = resultList[0]; + setLocalConfig(tempConfig); + let tempAccuracyOptions = resultList[1]; + setAccuracyActions(formatAccuracyForActions(tempAccuracyOptions)); + return formatConfigForDisplay(tempConfig, tempAccuracyOptions); + } + + useEffect(() => { + getCollectionSettings(); + }, [editVis]) + + const formatAccuracyForActions = function(accuracyOptions) { + let tempAccuracyActions = []; + for (var name in accuracyOptions) { + tempAccuracyActions.push({text: name, value: accuracyOptions[name]}); + } + return tempAccuracyActions; + } + + /* + * Functions to edit and save values + */ + + async function saveAndReload() { + console.log("new config = ", localConfig); + try{ + let set = await setConfig(localConfig); + //TODO find way to not need control.update.complete event broadcast + } catch(err) { + Logger.displayError("Error while setting collection config", err); + } + } + + const onToggle = function(config_key) { + let tempConfig = {...localConfig}; + tempConfig[config_key] = !localConfig[config_key]; + setLocalConfig(tempConfig); + } + + const onChooseAccuracy = function(accuracyOption) { + let tempConfig = {...localConfig}; + tempConfig.accuracy = accuracyOption.value; + setLocalConfig(tempConfig); + } + + const onChangeText = function(newText, config_key) { + let tempConfig = {...localConfig}; + tempConfig[config_key] = parseInt(newText); + setLocalConfig(tempConfig); + } + + /*ios vs android*/ + let filterComponent; + if(window['cordova'].platformId == 'ios') { + filterComponent = + Filter Distance + onChangeText(text, "filter_distance")}/> + + } else { + filterComponent = + Filter Interval + onChangeText(text, "filter_time")}/> + + } + let iosToggles; + if(window['cordova'].platformId == 'ios') { + iosToggles = <> + {/* use visit notifications toggle NO ANDROID */} + + Use Visit Notifications + onToggle("ios_use_visit_notifications_for_detection")}> + + {/* sync on remote push toggle NO ANDROID */} + + Sync on remote push + onToggle("ios_use_remote_push_for_sync}")}> + + + } + let geofenceComponent; + if(window['cordova'].platformId == 'android') { + geofenceComponent = + Geofence Responsiveness + onChangeText(text, "android_geofence_responsiveness")}/> + + } + + return ( + <> + setEditVis(false)} transparent={true}> + setEditVis(false)} style={settingStyles.dialog(colors.elevation.level3)}> + Edit Collection Settings + + {/* duty cycling toggle */} + + Duty Cycling + onToggle("is_duty_cycling")}> + + {/* simulate user toggle */} + + Simulate User + onToggle("simulate_user_interaction")}> + + {/* accuracy */} + + Accuracy + + + {/* accuracy threshold not editable*/} + + Accuracy Threshold + {localConfig?.accuracy_threshold} + + {filterComponent} + {/* geofence radius */} + + Geofence Radius + onChangeText(text, "geofence_radius")}/> + + {iosToggles} + {geofenceComponent} + + + + + + + + + {}}> + + ); + }; + +export default ControlSyncHelper; diff --git a/www/js/control/ControlSyncHelper.tsx b/www/js/control/ControlSyncHelper.tsx new file mode 100644 index 000000000..490672c4d --- /dev/null +++ b/www/js/control/ControlSyncHelper.tsx @@ -0,0 +1,284 @@ +import React, { useEffect, useState } from "react"; +import { Modal, View } from "react-native"; +import { Dialog, Button, Switch, Text, useTheme } from 'react-native-paper'; +import { useTranslation } from "react-i18next"; +import { settingStyles } from "./ProfileSettings"; +import { getAngularService } from "../angular-react-helper"; +import ActionMenu from "../components/ActionMenu"; +import SettingRow from "./SettingRow"; +import AlertBar from "./AlertBar"; +import moment from "moment"; + +/* +* BEGIN: Simple read/write wrappers +*/ +export function forcePluginSync() { + return window.cordova.plugins.BEMServerSync.forceSync(); +}; + +const formatConfigForDisplay = (configToFormat) => { + var formatted = []; + for (let prop in configToFormat) { + formatted.push({'key': prop, 'val': configToFormat[prop]}); + } + return formatted; +} + +const setConfig = function(config) { + return window.cordova.plugins.BEMServerSync.setConfig(config); + }; + +const getConfig = function() { + return window.cordova.plugins.BEMServerSync.getConfig(); +}; + +export async function getHelperSyncSettings() { + let tempConfig = await getConfig(); + return formatConfigForDisplay(tempConfig); +} + +const getEndTransitionKey = function() { + if(window.cordova.platformId == 'android') { + return "local.transition.stopped_moving"; + } + else if(window.cordova.platformId == 'ios') { + return "T_TRIP_ENDED"; + } +} + +type syncConfig = { sync_interval: number, + ios_use_remote_push: boolean }; + +//forceSync and endForceSync SettingRows & their actions +export const ForceSyncRow = ({getState}) => { + const { t } = useTranslation(); + const { colors } = useTheme(); + const ClientStats = getAngularService('ClientStats'); + const Logger = getAngularService('Logger'); + + const [dataPendingVis, setDataPendingVis] = useState(false); + const [dataPushedVis, setDataPushedVis] = useState(false); + + async function forceSync() { + try { + let addedEvent = ClientStats.addEvent(ClientStats.getStatKeys().BUTTON_FORCE_SYNC); + console.log("Added "+ClientStats.getStatKeys().BUTTON_FORCE_SYNC+" event"); + + let sync = await forcePluginSync(); + /* + * Change to sensorKey to "background/location" after fixing issues + * with getLastSensorData and getLastMessages in the usercache + * See https://github.com/e-mission/e-mission-phone/issues/279 for details + */ + var sensorKey = "statemachine/transition"; + let sensorDataList = await window.cordova.plugins.BEMUserCache.getAllMessages(sensorKey, true); + + // If everything has been pushed, we should + // have no more trip end transitions left + let isTripEnd = function(entry) { + return entry.metadata == getEndTransitionKey(); + } + let syncLaunchedCalls = sensorDataList.filter(isTripEnd); + let syncPending = syncLaunchedCalls.length > 0; + Logger.log("sensorDataList.length = "+sensorDataList.length+ + ", syncLaunchedCalls.length = "+syncLaunchedCalls.length+ + ", syncPending? = "+syncPending); + Logger.log("sync launched = "+syncPending); + + if(syncPending) { + Logger.log(Logger.log("data is pending, showing confirm dialog")); + setDataPendingVis(true); //consent handling in modal + } else { + setDataPushedVis(true); + } + } catch (error) { + Logger.displayError("Error while forcing sync", error); + } + }; + + const getStartTransitionKey = function() { + if(window.cordova.platformId == 'android') { + return "local.transition.exited_geofence"; + } + else if(window.cordova.platformId == 'ios') { + return "T_EXITED_GEOFENCE"; + } + } + + 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 getState(); + entry_data.curr_state = curr_state; + if (transKey == getEndTransitionKey()) { + entry_data.curr_state = getOngoingTransitionState(); + } + entry_data.transition = transKey; + entry_data.ts = moment().unix(); + return entry_data; + } + + async function endForceSync() { + /* First, quickly start and end the trip. Let's listen to the promise + * result for start so that we ensure ordering */ + var sensorKey = "statemachine/transition"; + let entry_data = await getTransition(getStartTransitionKey()); + let messagePut = await window.cordova.plugins.BEMUserCache.putMessage(sensorKey, entry_data); + entry_data = await getTransition(getEndTransitionKey()); + messagePut = await window.cordova.plugins.BEMUserCache.putMessage(sensorKey, entry_data); + forceSync(); + }; + + return ( + <> + + + + {/* dataPending */} + setDataPendingVis(false)} transparent={true}> + setDataPendingVis(false)} + style={settingStyles.dialog(colors.elevation.level3)}> + {t('data pending for push')} + + + + + + + + + + ) +} + +//UI for editing the sync config +const ControlSyncHelper = ({ editVis, setEditVis }) => { + const { t } = useTranslation(); + const { colors } = useTheme(); + const CommHelper = getAngularService("CommHelper"); + const Logger = getAngularService("Logger"); + + const [ localConfig, setLocalConfig ] = useState(); + const [ intervalVis, setIntervalVis ] = useState(false); + + /* + * Functions to read and format values for display + */ + async function getSyncSettings() { + let tempConfig = await getConfig(); + setLocalConfig(tempConfig); + } + + useEffect(() => { + getSyncSettings(); + }, [editVis]) + + const syncIntervalActions = [ + {text: "1 min", value: 60}, + {text: "10 min", value: 10 * 60}, + {text: "30 min", value: 30 * 60}, + {text: "1 hr", value: 60 * 60} + ] + + /* + * Functions to edit and save values + */ + async function saveAndReload() { + console.log("new config = "+localConfig); + try{ + let set = setConfig(localConfig); + //NOTE -- we need to make sure we update these settings in ProfileSettings :) -- getting rid of broadcast handling for migration!! + CommHelper.updateUser({ + // TODO: worth thinking about where best to set this + // Currently happens in native code. Now that we are switching + // away from parse, we can store this from javascript here. + // or continue to store from native + // this is easier for people to see, but means that calls to + // native, even through the javascript interface are not complete + curr_sync_interval: localConfig.sync_interval + }); + } catch (err) + { + console.log("error with setting sync config", err); + Logger.displayError("Error while setting sync config", err); + } + } + + const onChooseInterval = function(interval) { + let tempConfig = {...localConfig}; + tempConfig.sync_interval = interval.value; + setLocalConfig(tempConfig); + } + + const onTogglePush = function() { + let tempConfig = {...localConfig}; + tempConfig.ios_use_remote_push = !localConfig.ios_use_remote_push; + setLocalConfig(tempConfig); + } + + /* + * configure the UI + */ + let toggle; + if(window.cordova.platformId == 'ios'){ + toggle = + Use Remote Push + + + } + + return ( + <> + {/* popup to show when we want to edit */} + setEditVis(false)} transparent={true}> + setEditVis(false)} style={settingStyles.dialog(colors.elevation.level3)}> + Edit Sync Settings + + + Sync Interval + + + {toggle} + + + + + + + + + {}}> + + ); + }; + +export default ControlSyncHelper; \ No newline at end of file diff --git a/www/js/control/LogPage.tsx b/www/js/control/LogPage.tsx new file mode 100644 index 000000000..7d6d279ee --- /dev/null +++ b/www/js/control/LogPage.tsx @@ -0,0 +1,153 @@ +import React, { useState, useMemo, useEffect } from "react"; +import { View, StyleSheet, SafeAreaView, Modal } from "react-native"; +import { useTheme, Text, Appbar, IconButton } from "react-native-paper"; +import { getAngularService } from "../angular-react-helper"; +import { useTranslation } from "react-i18next"; +import { FlashList } from '@shopify/flash-list'; +import moment from "moment"; +import AlertBar from "./AlertBar"; + +type loadStats = { currentStart: number, gotMaxIndex: boolean, reachedEnd: boolean }; + +const LogPage = ({pageVis, setPageVis}) => { + const { t } = useTranslation(); + const { colors } = useTheme(); + const EmailHelper = getAngularService('EmailHelper'); + + const [ loadStats, setLoadStats ] = useState(); + const [ entries, setEntries ] = useState([]); + const [ maxErrorVis, setMaxErrorVis ] = useState(false); + const [ logErrorVis, setLogErrorVis ] = useState(false); + const [ maxMessage, setMaxMessage ] = useState(""); + const [ logMessage, setLogMessage ] = useState(""); + const [ isFetching, setIsFetching ] = useState(false); + + var RETRIEVE_COUNT = 100; + + //when opening the modal, load the entries + useEffect(() => { + refreshEntries(); + }, [pageVis]); + + async function refreshEntries() { + try { + let maxIndex = await window.Logger.getMaxIndex(); + console.log("maxIndex = "+maxIndex); + let tempStats = {} as loadStats; + tempStats.currentStart = maxIndex; + tempStats.gotMaxIndex = true; + tempStats.reachedEnd = false; + setLoadStats(tempStats); + setEntries([]); + } catch(error) { + let errorString = t('errors.while-max-index')+JSON.stringify(error, null, 2); + console.log(errorString); + setMaxMessage(errorString); + setMaxErrorVis(true); + } finally { + addEntries(); + } + } + + const moreDataCanBeLoaded = useMemo(() => { + return loadStats?.gotMaxIndex && !loadStats?.reachedEnd; + }, [loadStats]) + + const clear = function() { + window?.Logger.clearAll(); + window?.Logger.log(window.Logger.LEVEL_INFO, "Finished clearing entries from unified log"); + refreshEntries(); + } + + async function addEntries() { + console.log("calling addEntries"); + setIsFetching(true); + let start = loadStats.currentStart ? loadStats.currentStart : 0; //set a default start to prevent initial fetch error + try { + let entryList = await window.Logger.getMessagesFromIndex(start, RETRIEVE_COUNT); + processEntries(entryList); + console.log("entry list size = "+ entries.length); + setIsFetching(false); + } catch(error) { + let errStr = t('errors.while-log-messages')+JSON.stringify(error, null, 2); + console.log(errStr); + setLogMessage(errStr); + setLogErrorVis(true); + setIsFetching(false); + } + } + + const processEntries = function(entryList) { + let tempEntries = []; + let tempLoadStats = {...loadStats}; + entryList.forEach(e => { + e.fmt_time = moment.unix(e.ts).format("llll"); + tempEntries.push(e); + }); + if (entryList.length == 0) { + console.log("Reached the end of the scrolling"); + tempLoadStats.reachedEnd = true; + } else { + tempLoadStats.currentStart = entryList[entryList.length-1].ID; + console.log("new start index = "+loadStats.currentStart); + } + setEntries([...entries].concat(tempEntries)); //push the new entries onto the list + setLoadStats(tempLoadStats); + } + + const emailLog = function () { + EmailHelper.sendEmail("loggerDB"); + } + + const separator = () => + const logItem = ({item: logItem}) => ( + {logItem.fmt_time} + {logItem.ID + "|" + logItem.level + "|" + logItem.message} + ); + + return ( + setPageVis(false)}> + + + {setPageVis(false)}}/> + + + + + refreshEntries()}/> + clear()}/> + emailLog()}/> + + + item.ID} + ItemSeparatorComponent={separator} + onEndReachedThreshold={0.5} + refreshing={isFetching} + onRefresh={() => {if(moreDataCanBeLoaded){addEntries()}}} + onEndReached={() => {if(moreDataCanBeLoaded){addEntries()}}} + /> + + + + + + ); +}; +const styles = StyleSheet.create({ + date: (surfaceColor) => ({ + backgroundColor: surfaceColor, + }), + details: { + fontFamily: "monospace", + }, + entry: (surfaceColor) => ({ + backgroundColor: surfaceColor, + marginLeft: 5, + }), + }); + +export default LogPage; \ No newline at end of file diff --git a/www/js/control/PopOpCode.jsx b/www/js/control/PopOpCode.jsx index 8325b98c7..721b3d511 100644 --- a/www/js/control/PopOpCode.jsx +++ b/www/js/control/PopOpCode.jsx @@ -4,6 +4,7 @@ import { Button, Text, IconButton, Dialog, useTheme } from 'react-native-paper'; import { useTranslation } from "react-i18next"; import QrCode from "../components/QrCode"; import AlertBar from "./AlertBar"; +import { settingStyles } from "./ProfileSettings"; const PopOpCode = ({visibilityValue, tokenURL, action, setVis, dialogStyle}) => { const { t } = useTranslation(); @@ -31,7 +32,7 @@ const PopOpCode = ({visibilityValue, tokenURL, action, setVis, dialogStyle}) => transparent={true}> setVis(false)} - style={dialogStyle}> + style={settingStyles.dialog(colors.elevation.level3)}> {t("general-settings.qrcode")} {t("general-settings.qrcode-share-title")} diff --git a/www/js/control/PrivacyPolicyModal.tsx b/www/js/control/PrivacyPolicyModal.tsx index 7451581a6..9a0b06d07 100644 --- a/www/js/control/PrivacyPolicyModal.tsx +++ b/www/js/control/PrivacyPolicyModal.tsx @@ -4,8 +4,9 @@ import { Dialog, Button, useTheme } from 'react-native-paper'; import { useTranslation } from "react-i18next"; import useAppConfig from "../useAppConfig"; import i18next from "i18next"; +import { settingStyles } from "./ProfileSettings"; -const PrivacyPolicyModal = ({ privacyVis, setPrivacyVis, dialogStyle }) => { +const PrivacyPolicyModal = ({ privacyVis, setPrivacyVis }) => { const { t } = useTranslation(); const { height: windowHeight } = useWindowDimensions(); const { colors } = useTheme(); @@ -40,7 +41,7 @@ const PrivacyPolicyModal = ({ privacyVis, setPrivacyVis, dialogStyle }) => { setPrivacyVis(false)} transparent={true}> setPrivacyVis(false)} - style={dialogStyle}> + style={settingStyles.dialog(colors.elevation.level3)}> {t('consent-text.title')} diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index e3dab82e3..dcbedbd78 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -14,8 +14,11 @@ import AlertBar from "./AlertBar"; import DataDatePicker from "./DataDatePicker"; import AppStatusModal from "./AppStatusModal"; import PrivacyPolicyModal from "./PrivacyPolicyModal"; - -let controlUpdateCompleteListenerRegistered = false; +import ActionMenu from "../components/ActionMenu"; +import SensedPage from "./SensedPage" +import LogPage from "./LogPage"; +import ControlSyncHelper, {ForceSyncRow, getHelperSyncSettings} from "./ControlSyncHelper"; +import ControlCollectionHelper, {getHelperCollectionSettings, getState, isMediumAccuracy, helperToggleLowAccuracy, forceTransition} from "./ControlCollectionHelper"; //any pure functions can go outside const ProfileSettings = () => { @@ -24,20 +27,10 @@ const ProfileSettings = () => { const { appConfig, loading } = useAppConfig(); const { colors } = useTheme(); - // get the scope of the general-settings.js file - 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 { showLog, showSensed } = settingsScope; - //angular services needed const CarbonDatasetHelper = getAngularService('CarbonDatasetHelper'); const UploadHelper = getAngularService('UploadHelper'); const EmailHelper = getAngularService('EmailHelper'); - const ControlCollectionHelper = getAngularService('ControlCollectionHelper'); - const ControlSyncHelper = getAngularService('ControlSyncHelper'); const KVStore = getAngularService('KVStore'); const NotificationScheduler = getAngularService('NotificationScheduler'); const ControlHelper = getAngularService('ControlHelper'); @@ -45,18 +38,9 @@ const ProfileSettings = () => { const StartPrefs = getAngularService('StartPrefs'); const DynamicConfig = getAngularService('DynamicConfig'); - if (!controlUpdateCompleteListenerRegistered) { - settingsScope.$on('control.update.complete', function() { - console.debug("Received control.update.complete event, refreshing screen"); - refreshScreen(); - refreshCollectSettings(); - }); - controlUpdateCompleteListenerRegistered = true; - } - //functions that come directly from an Angular service - const editCollectionConfig = ControlCollectionHelper.editConfig; - const editSyncConfig = ControlSyncHelper.editConfig; + const editCollectionConfig = () => setEditCollection(true); + const editSyncConfig = () => setEditSync(true); //states and variables used to control/create the settings const [opCodeVis, setOpCodeVis] = useState(false); @@ -65,15 +49,18 @@ const ProfileSettings = () => { 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 [showingSensed, setShowingSensed] = useState(false); + const [showingLog, setShowingLog] = useState(false); + const [editSync, setEditSync] = useState(false); + const [editCollection, setEditCollection] = useState(false); + // const [collectConfig, setCollectConfig] = useState({}); const [collectSettings, setCollectSettings] = useState({}); const [notificationSettings, setNotificationSettings] = useState({}); const [authSettings, setAuthSettings] = useState({}); @@ -84,7 +71,6 @@ const ProfileSettings = () => { 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(); @@ -149,16 +135,16 @@ const ProfileSettings = () => { console.debug('about to refreshCollectSettings, collectSettings = ', collectSettings); const newCollectSettings = {}; - // refresh collect plugin configuration - const collectionPluginConfig = await ControlCollectionHelper.getCollectionSettings(); + // // refresh collect plugin configuration + const collectionPluginConfig = await getHelperCollectionSettings(); newCollectSettings.config = collectionPluginConfig; - const collectionPluginState = await ControlCollectionHelper.getState(); + const collectionPluginState = await getState(); newCollectSettings.state = collectionPluginState; newCollectSettings.trackingOn = collectionPluginState != "local.state.tracking_stopped" && collectionPluginState != "STATE_TRACKING_STOPPED"; - const isLowAccuracy = await ControlCollectionHelper.isMediumAccuracy(); + const isLowAccuracy = await isMediumAccuracy(); if (typeof isLowAccuracy != 'undefined') { newCollectSettings.lowAccuracy = isLowAccuracy; } @@ -166,6 +152,11 @@ const ProfileSettings = () => { setCollectSettings(newCollectSettings); } + //ensure ui table updated when editor closes + useEffect(() => { + refreshCollectSettings(); + }, [editCollection]) + async function refreshNotificationSettings() { console.debug('about to refreshNotificationSettings, notificationSettings = ', notificationSettings); const newNotificationSettings ={}; @@ -188,13 +179,18 @@ const ProfileSettings = () => { async function getSyncSettings() { console.log("getting sync settings"); var newSyncSettings = {}; - ControlSyncHelper.getSyncSettings().then(function(showConfig) { + getHelperSyncSettings().then(function(showConfig) { newSyncSettings.show_config = showConfig; setSyncSettings(newSyncSettings); console.log("sync settings are ", syncSettings); }); }; + //update sync settings in the table when close editor + useEffect(() => { + getSyncSettings(); + }, [editSync]); + async function getConnectURL() { ControlHelper.getSettings().then(function(response) { var newConnectSettings ={} @@ -254,30 +250,12 @@ const ProfileSettings = () => { async function userStartStopTracking() { const transitionToForce = collectSettings.trackingOn ? 'STOP_TRACKING' : 'START_TRACKING'; - ControlCollectionHelper.forceTransition(transitionToForce); - /* the ControlCollectionHelper.forceTransition call above will trigger a - 'control.update.complete' event when it's done, which will trigger refreshCollectSettings. - So we don't need to call refreshCollectSettings here. */ - } - - - const 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()); - } + forceTransition(transitionToForce); + refreshCollectSettings(); } async function toggleLowAccuracy() { - ControlCollectionHelper.toggleLowAccuracy(); + let toggle = await helperToggleLowAccuracy(); refreshCollectSettings(); } @@ -321,46 +299,6 @@ const ProfileSettings = () => { //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 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) { @@ -376,64 +314,6 @@ const ProfileSettings = () => { } } - 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); @@ -458,6 +338,17 @@ const ProfileSettings = () => { }); } + const onSelectState = function(stateObject) { + forceTransition(stateObject.transition); + } + + const onSelectCarbon = function(carbonObject) { + console.log("changeCarbonDataset(): chose locale " + carbonObject.value); + CarbonDatasetHelper.saveCurrentCarbonDatasetLocale(carbonObject.value); //there's some sort of error here + //Unhandled Promise Rejection: While logging, error -[NSNull UTF8String]: unrecognized selector sent to instance 0x7fff8a625fb0 + carbonDatasetString = i18next.t('general-settings.carbon-dataset') + ": " + CarbonDatasetHelper.getCurrentCarbonDatasetCode(); + } + //conditional creation of setting sections let logUploadSection; @@ -484,30 +375,29 @@ const ProfileSettings = () => { - + setPrivacyVis(true)}> {timePicker} setPermitVis(true)}> - + setCarbonDataVis(true)}> - setDateDumpVis(true)}> {logUploadSection} - + {notifSchedule} setNukeVis(true)}> setForceStateVis(true)}> - - + setShowingLog(true)}> + setShowingSensed(true)}> @@ -521,7 +411,7 @@ const ProfileSettings = () => { transparent={true}> setNukeVis(false)} - style={styles.dialog(colors.elevation.level3)}> + style={settingStyles.dialog(colors.elevation.level3)}> {t('general-settings.clear-data')} - )} - - - - - - + clearNotifications()}> {/* force state sheet */} - setForceStateVis(false)} - transparent={true}> - setForceStateVis(false)} - style={styles.dialog(colors.elevation.level3)}> - {"Force State"} - - {stateActions.map((e) => - - )} - - - - - - + {}}> {/* opcode viewing popup */} - + {/* {view permissions} */} - + {/* {view privacy} */} - - + + {/* logout menu */} setLogoutVis(false)} transparent={true}> setLogoutVis(false)} - style={styles.dialog(colors.elevation.level3)}> + style={settingStyles.dialog(colors.elevation.level3)}> {t('general-settings.are-you-sure')} {t('general-settings.log-out-warning')} @@ -629,32 +470,11 @@ const ProfileSettings = () => { - {/* 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)}> + style={settingStyles.dialog(colors.elevation.level3)}> {t('general-settings.consent-not-found')} - - - - - -
-
-
{{'main-metrics.summary'}}
-
{{'main-metrics.chart'}}
-
-
-
-
{{'main-metrics.change-data'}}
-
-
-
-
{{ selectCtrl.fromDateTimestamp.format('ll') }} ➡️ {{ selectCtrl.toDateTimestamp.format('ll') }}
-
-
-
-
- - - -
-
-
-
-
{{'main-metrics.distance'}}
-
{{'main-metrics.trips'}}
-
{{'main-metrics.duration'}}
-
{{'main-metrics.speed'}}
-
-
-
-
- - - - -
-
-
-
-
-
-
-
-

{{'main-metrics.footprint'}}

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

{{'main-metrics.calories'}}

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

{{'main-metrics.distance'}}

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

{{'main-metrics.trips'}}

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

{{'main-metrics.duration'}}

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

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

-
{{'main-metrics.no-summary-data'}}
-
-
- -
- -
-
-
- {{ summaryData.defaultSummary.mean_speed[dIndex + i].key }} -
-
- {{ formatMeanSpeed(summaryData.defaultSummary.mean_speed[dIndex + i].values).slice(-1)[0] }} -
-
-
-
-
-
-
-
-
-
- diff --git a/www/templates/recent/log.html b/www/templates/recent/log.html deleted file mode 100644 index 455294705..000000000 --- a/www/templates/recent/log.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - -
- - - -
- - -
{{entry.fmt_time}}
-
{{entry.ID}} | {{entry.level}} | {{entry.message}}
-
- -
-
-
diff --git a/www/templates/recent/sensedData.html b/www/templates/recent/sensedData.html deleted file mode 100644 index 5f631635f..000000000 --- a/www/templates/recent/sensedData.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - -
- - -
- - -
{{entry.metadata.write_fmt_time}}
-
{{entry.data}}
-
-
-
-