diff --git a/messages/renderer/en.json b/messages/renderer/en.json index 78ba6e8df..bd47ccdbc 100644 --- a/messages/renderer/en.json +++ b/messages/renderer/en.json @@ -1,4 +1,60 @@ { + "renderer.components.BackgroundMaps.BackgroundMapInfo.createOfflineArea": { + "description": "Button to create an offline area", + "message": "Create Offline Area" + }, + "renderer.components.BackgroundMaps.BackgroundMapInfo.currentMap": { + "description": "Button text for 'Use Map' button when map is selected", + "message": "Current Map" + }, + "renderer.components.BackgroundMaps.BackgroundMapInfo.deleteErrorDescription": { + "description": "Description for error message when deleting style,", + "message": "There was an error deleting the style" + }, + "renderer.components.BackgroundMaps.BackgroundMapInfo.deleteErrorTitle": { + "description": "Title for error message when deleting style", + "message": "Error Deleting Style" + }, + "renderer.components.BackgroundMaps.BackgroundMapInfo.deleteStyle": { + "description": "Button to delete style", + "message": "Delete Style" + }, + "renderer.components.BackgroundMaps.BackgroundMapInfo.mb": { + "description": "abbreviation for megabyte", + "message": "MB" + }, + "renderer.components.BackgroundMaps.BackgroundMapInfo.offlineAreas": { + "description": "Title for Offline Areas", + "message": "Offline Areas" + }, + "renderer.components.BackgroundMaps.BackgroundMapInfo.useMap": { + "description": "Button text for 'Use Map' button", + "message": "Use Map" + }, + "renderer.components.BackgroundMaps.BackgroundMapInfo.zoomLevel": { + "description": "Zoom Level Title", + "message": "Zoom Level: {zoom}" + }, + "renderer.components.BackgroundMaps.MapCard.areas": { + "description": "indicates how many offline areas", + "message": "offline areas" + }, + "renderer.components.BackgroundMaps.MapCard.currentMap": { + "description": "Label to indicate when map is selected", + "message": "Current Map" + }, + "renderer.components.BackgroundMaps.MapCard.mb": { + "description": "Abbreviation for megabytes", + "message": "MB" + }, + "renderer.components.BackgroundMaps.SidePanel.addMap": { + "description": "Button to add map background", + "message": "Add Map Background" + }, + "renderer.components.BackgroundMaps.SidePanel.backToSettings": { + "description": "button to go back to settings", + "message": "Back to Settings" + }, "renderer.components.Home.mapeditor": { "description": "MapEditor tab label", "message": "Territory" @@ -339,6 +395,18 @@ "renderer.components.MapFilter.MapFilter.errorTitle": { "message": "Oh dear! An error has occurred" }, + "renderer.components.MapFilter.MapView.BackgroundMapSelector.close": { + "description": "Label for dismiss button", + "message": "Close" + }, + "renderer.components.MapFilter.MapView.BackgroundMapSelector.manageMapsLink": { + "description": "Label for link to manage maps", + "message": "Manage Maps" + }, + "renderer.components.MapFilter.MapView.BackgroundMapSelector.title": { + "description": "Title for background maps overlay", + "message": "Background Maps" + }, "renderer.components.MapFilter.ObservationDialog.ObservationDialog.additionalHeader": { "description": "Header for section with additional fields that are not defined in the preset", "message": "Additional data" @@ -474,12 +542,44 @@ "renderer.components.SettingsView.AboutMapeo.mapeoVersion": { "message": "Mapeo Version" }, + "renderer.components.SettingsView.BackgroundMaps.introText": { + "description": "Text introducing the Background Maps feature", + "message": "The Background Map in Mapeo is displayed on the observation screen and is used as a background for the observations you collect. This new pilot feature allows you to add your own custom maps and switch between multiple maps. Background Maps is currently an advanced feature - an existing map file in .mbtiles format is required for testing" + }, + "renderer.components.SettingsView.BackgroundMaps.mapBackgroundTitle": { + "description": "Title for description of offline maps", + "message": "Managing Map Backgrounds and Offline Areas" + }, + "renderer.components.SettingsView.ExperimentsMenu.backgroundMaps": { + "message": "Background Maps" + }, + "renderer.components.SettingsView.SettingsItem.off": { + "message": "Off" + }, + "renderer.components.SettingsView.SettingsItem.on": { + "message": "On" + }, + "renderer.components.SettingsView.SettingsList.off": { + "message": "Off" + }, + "renderer.components.SettingsView.SettingsList.on": { + "message": "On" + }, "renderer.components.SettingsView.index.aboutMapeo": { "message": "About Mapeo" }, "renderer.components.SettingsView.index.aboutMapeoSubtitle": { "message": "Version and build number" }, + "renderer.components.SettingsView.index.backgroundMaps": { + "message": "Background maps" + }, + "renderer.components.SettingsView.index.experiments": { + "message": "Experiments" + }, + "renderer.components.SettingsView.index.experimentsSubtitle": { + "message": "Turn on experimental new features" + }, "renderer.components.SyncView.Searching.searchingHint": { "description": "Hint on sync screen when searching on wifi for devices", "message": "Make sure devices are turned on and connected to the same wifi network" @@ -636,6 +736,26 @@ "renderer.components.dialogs.Error.openLog": { "message": "Open log..." }, + "renderer.components.dialogs.ImportMapStyle.addMap": { + "description": "Title of screen used to add a new background map", + "message": "Add Map Background" + }, + "renderer.components.dialogs.ImportMapStyle.cancel": { + "description": "button to cancel the import of a background map", + "message": "Cancel" + }, + "renderer.components.dialogs.ImportMapStyle.importErrorDescription": { + "description": "Description of map import error", + "message": "There was an error importing the background maps. Please try again. Error message:" + }, + "renderer.components.dialogs.ImportMapStyle.importErrorTitle": { + "description": "Title for import errot pop up dialog,", + "message": "Background Maps Import Error" + }, + "renderer.components.dialogs.ImportMapStyle.importFile": { + "description": "Label of 'Import File' button", + "message": "Import File" + }, "renderer.components.dialogs.LatLon.button-submit": { "message": "Submit" }, diff --git a/src/main/ipc.js b/src/main/ipc.js index ec2d97fa0..d4bb341f9 100644 --- a/src/main/ipc.js +++ b/src/main/ipc.js @@ -95,6 +95,19 @@ module.exports = function (ipcSend) { return i18n.t('closing-screen') }) + ipcMain.handle('select-mb-tile-file', async function () { + const result = await dialog.showOpenDialog({ + filters: [{ name: 'MbTiles', extensions: ['mbtiles'] }], + properties: ['openFile'] + }) + + return result + }) + + ipcMain.on('show-error-dialog', function (event, title, content) { + dialog.showErrorBox(title, content) + }) + ipcMain.on('save-file', function () { var metadata = userConfig.getSettings('metadata') var ext = metadata ? metadata.dataset_id : 'mapeodata' diff --git a/src/renderer/components/BackgroundMaps/BackgroundMapInfo.js b/src/renderer/components/BackgroundMaps/BackgroundMapInfo.js new file mode 100644 index 000000000..565daf8d5 --- /dev/null +++ b/src/renderer/components/BackgroundMaps/BackgroundMapInfo.js @@ -0,0 +1,206 @@ +// @ts-check +import { Button, Fade, makeStyles, Paper, Typography } from '@material-ui/core' +import * as React from 'react' +import { defineMessages, useIntl } from 'react-intl' +import DeleteIcon from '@material-ui/icons/DeleteForeverOutlined' +import CheckIcon from '@material-ui/icons/Check' +import ReactMapboxGl from 'react-mapbox-gl' + +import { MAPBOX_ACCESS_TOKEN } from '../../../../config' +import Loading from '../Loading' +import { convertKbToMb } from '../SettingsView/BackgroundMaps' +import { useMapServerMutation } from '../../hooks/useMapServerMutation' +import { useBackgroundMapStore } from '../../hooks/store' +import { DEFAULT_MAP, useMapStylesQuery } from '../../hooks/useMapStylesQuery' + +const MapboxPrevOnly = ReactMapboxGl({ + accessToken: MAPBOX_ACCESS_TOKEN, + dragRotate: false, + pitchWithRotate: false, + attributionControl: false, + injectCSS: false +}) + +const m = defineMessages({ + // Title for Offline Areas + offlineAreas: 'Offline Areas', + // Button to create an offline area + createOfflineArea: 'Create Offline Area', + // Button to delete style + deleteStyle: 'Delete Style', + // Title for error message when deleting style + deleteErrorTitle: 'Error Deleting Style', + // Description for error message when deleting style, + deleteErrorDescription: 'There was an error deleting the style', + // Zoom Level Title + zoomLevel: 'Zoom Level: {zoom}', + // abbreviation for megabyte + mb: 'MB', + // Button text for 'Use Map' button + useMap: 'Use Map', + // Button text for 'Use Map' button when map is selected + currentMap: 'Current Map' +}) + +/** + * @typedef {import('../../hooks/store').MapStyle} BackgroundMapInfo + * @typedef BackgroundMapInfoProps + * @prop {BackgroundMapInfo} map + * @prop {()=>void} unsetMapValue + */ + +/** @param {BackgroundMapInfoProps} props */ +export const BackgroundMapInfo = ({ map, unsetMapValue }) => { + const { formatMessage: t } = useIntl() + + const [mapStyle, setMapStyle] = useBackgroundMapStore() + + const classes = useStyles() + + const isCurrentMap = mapStyle?.id === map.id + + return ( + + + {!map ? ( + + ) : ( + <> + + {/* Text */} +
+ + {typeof map.name === 'string' ? map.name : t(map.name)} + + {!map.isDefault ? ( + {`${Math.round( + convertKbToMb(map.bytesStored) + )} ${t(m.mb)}`} + ) : null} + +
+ + )} +
+
+ ) +} + +/** + * @typedef MapInfoProps + * @prop {string|null|undefined} name + * @prop {string} id + * @prop {()=>void} unsetMapValue + * @prop {string} url + * @prop {boolean | undefined} isDefault + * @prop {boolean} isCurrentMap + */ + +/** @param {MapInfoProps} props */ +const MapInfo = ({ name, id, isDefault, unsetMapValue, url, isCurrentMap }) => { + const classes = useStyles() + + const { formatMessage: t } = useIntl() + + const { data: mapsData } = useMapStylesQuery() + const [, setMapStyle] = useBackgroundMapStore() + const mutation = useMapServerMutation('delete', `/styles/${id}`) + + async function deleteMap () { + if (isCurrentMap && mapsData?.length) { + setMapStyle(DEFAULT_MAP) + } + await mutation.mutateAsync(null) // tc complains if no arg is passed... + } + + return ( + <> + {/* Banner */} + + {name} + +
+ {!isDefault && ( + + )} +
+
+ + {/* Map */} + + + ) +} + +const useStyles = makeStyles({ + buttonContainer: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center' + }, + banner: { + width: '100%', + display: 'flex', + justifyContent: 'space-between', + padding: '10px 20px', + borderRadius: 0 + }, + textBanner: { + display: 'flex', + justifyContent: 'space-evenly' + }, + offlineCardContainer: { + display: 'flex', + flexWrap: 'wrap', + justifyContent: 'space-evenly' + }, + paddedContainer: { + padding: 20 + }, + paddedButton: { + marginTop: 20 + }, + iconButton: { + padding: '5px 10px 5px 15px' + }, + icon: { + height: 20 + } +}) diff --git a/src/renderer/components/BackgroundMaps/MapCard.js b/src/renderer/components/BackgroundMaps/MapCard.js new file mode 100644 index 000000000..91c1000e7 --- /dev/null +++ b/src/renderer/components/BackgroundMaps/MapCard.js @@ -0,0 +1,138 @@ +// @ts-check +import * as React from 'react' +import Button from '@material-ui/core/Button' +import { makeStyles, Typography, useTheme } from '@material-ui/core' +import { useIntl, defineMessages } from 'react-intl' +import ReactMapboxGl from 'react-mapbox-gl' + +import { MAPBOX_ACCESS_TOKEN } from '../../../../config' +import { convertKbToMb } from '../SettingsView/BackgroundMaps' +import { useBackgroundMapStore } from '../../hooks/store' +import Chip from '@material-ui/core/Chip' + +const m = defineMessages({ + // Abbreviation for megabytes + mb: 'MB', + // indicates how many offline areas + areas: 'offline areas', + // Label to indicate when map is selected + currentMap: 'Current Map' +}) + +export const MapboxPrevOnly = ReactMapboxGl({ + accessToken: MAPBOX_ACCESS_TOKEN, + interactive: false, + injectCSS: false +}) + +/** + * @typedef {import('../../hooks/store').MapStyle} MapStyle + * @typedef MapCardProps + * @prop {MapStyle} mapStyle + * @prop {React.Dispatch>} setMap + * @prop {boolean } isBeingViewed + */ + +/** @param {MapCardProps} param */ +export const MapCard = ({ mapStyle, setMap, isBeingViewed }) => { + const theme = useTheme() + const [selectedMapStyle] = useBackgroundMapStore() + const classes = useStyles() + const { formatMessage: t } = useIntl() + + return ( + + ) +} + +const useStyles = makeStyles({ + root: { + minHeight: '90px', + width: '90%', + marginBottom: 20, + textTransform: 'none', + padding: 0, + '& .MuiButton-root': { + padding: 0 + }, + '& .MuiButton-outlined': { + padding: 0 + }, + '& .MuiButton-label': { + height: '100%' + } + }, + inner: { + display: 'flex', + flex: 1, + height: '100%' + }, + text: { + alignItems: 'flex-start', + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + flex: 1, + height: '100%', + marginLeft: 10 + }, + detail: { + alignItems: 'flex-end', + display: 'flex', + flexDirection: 'column', + height: '100%', + justifyContent: 'flex-end', + padding: 8 + } +}) diff --git a/src/renderer/components/BackgroundMaps/SidePanel.js b/src/renderer/components/BackgroundMaps/SidePanel.js new file mode 100644 index 000000000..8edde5e81 --- /dev/null +++ b/src/renderer/components/BackgroundMaps/SidePanel.js @@ -0,0 +1,116 @@ +// @ts-check +import { Button, makeStyles } from '@material-ui/core' +import ChevronLeft from '@material-ui/icons/ChevronLeft' +import * as React from 'react' +import { defineMessages, useIntl } from 'react-intl' +import { ImportMapStyleDialog } from '../dialogs/ImportMapStyle' +import Loader from '../Loader' +import { MapCard } from './MapCard' +import { useMapStylesQuery } from '../../hooks/useMapStylesQuery' + +const m = defineMessages({ + // Button to add map background + addMap: 'Add Map Background', + // button to go back to settings + backToSettings: 'Back to Settings' +}) +/** + * @typedef SidePanelProps + * @prop {()=>void} openSettings + * @prop {string|null} mapValue + * @prop {React.Dispatch>} setMapValue + */ + +/** @param {SidePanelProps} param */ +export const SidePanel = ({ openSettings, mapValue, setMapValue }) => { + const { formatMessage: t } = useIntl() + + const classes = useStyles() + const [open, setOpen] = React.useState(false) + + const { data, isLoading, refetch } = useMapStylesQuery(false) + + return ( + <> +
+ +
+
+ +
+ + {isLoading ? ( + + ) : data ? ( + data.map(mapStyle => ( + + + + )) + ) : null} +
+
+ setOpen(false)} + refetch={refetch} + /> + + ) +} + +const useStyles = makeStyles({ + sidePanel: { + width: 'auto', + borderRight: '1px solid #E0E0E0', + height: '100%', + display: 'flex', + flexDirection: 'column', + minWidth: '35%' + }, + stylesColumn: { + height: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + overflowY: 'scroll' + }, + buttonContainer: { + display: 'flex', + justifyContent: 'space-between', + padding: 20 + }, + button: { + textTransform: 'none', + fontSize: 12 + }, + firstButton: { + marginRight: 10 + }, + backHeader: { + justifyContent: 'flex-start', + alignSelf: 'flex-start', + paddingLeft: 20, + paddingTop: 20, + paddingBottom: 20, + width: '100%', + display: 'flex', + textTransform: 'none', + '& :first-child': { + marginRight: 20 + } + } +}) diff --git a/src/renderer/components/MapFilter/MapView/BackgroundMapSelector.js b/src/renderer/components/MapFilter/MapView/BackgroundMapSelector.js new file mode 100644 index 000000000..d03872ab2 --- /dev/null +++ b/src/renderer/components/MapFilter/MapView/BackgroundMapSelector.js @@ -0,0 +1,165 @@ +// @ts-check +import * as React from 'react' +import { + Box, + Button, + Dialog, + Link, + Slide, + Typography, + makeStyles +} from '@material-ui/core' +import { defineMessages, useIntl } from 'react-intl' +import { Close } from '@material-ui/icons' + +import { MapPreviewCard } from './MapPreviewCard' +import { + useBackgroundMapStore, + usePersistedUiStore +} from '../../../hooks/store' +import { useMapStylesQuery } from '../../../hooks/useMapStylesQuery' +import Loader from '../../Loader' + +const MAPEO_BLUE = '#2469f6' + +const m = defineMessages({ + // Title for background maps overlay + title: 'Background Maps', + // Label for dismiss button + close: 'Close', + // Label for link to manage maps + manageMapsLink: 'Manage Maps' +}) + +export const BackgroundMapSelector = ({ active, dismiss }) => { + const classes = useStyles() + const { formatMessage: t } = useIntl() + const [mapStyle, setMapStyle] = useBackgroundMapStore() + + const setTabIndex = usePersistedUiStore(store => store.setTabIndex) + + const navigateToBackgroundMaps = () => { + dismiss() + setTabIndex(3) // TODO: Set from an ID rather than hardcoded index + } + + const { isLoading, data: mapStyles } = useMapStylesQuery() + + return ( + + {/* HEADER */} + + + + {t(m.title)} + + + {t(m.manageMapsLink)} + + + + + {/* MAP STYLES ROW */} + {isLoading ? ( + + ) : ( + + {mapStyles + .filter(({ isImporting }) => !isImporting) + .map(map => { + const isSelected = mapStyle?.id === map.id + return ( + { + if (isSelected) return + dismiss() + setMapStyle(map) + }} + selected={isSelected} + styleUrl={map.url} + title={typeof map.name === 'string' ? map.name : t(map.name)} + key={map.id} + /> + ) + })} + + )} + + ) +} + +const Loading = () => { + const classes = useStyles() + + return ( + + + + ) +} + +const Transition = React.forwardRef((props, ref) => ( + +)) + +const useStyles = makeStyles(theme => ({ + container: { + borderRadius: '10px 10px 0px 0px', + width: '100%', + minHeight: '40vh', + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + zIndex: 2 + }, + header: { + padding: '16px 22px', + display: 'flex', + justifyContent: 'space-between', + borderBottom: '1px solid rgba(0, 0, 0, 0.12)', + alignItems: 'center' + }, + title: { + fontSize: 22, + fontWeight: 500, + marginRight: 10 + }, + row: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center' + }, + styleRow: { + display: 'flex', + padding: 22, + flexWrap: 'wrap', + gap: '40px 20px ' + }, + link: { + cursor: 'pointer', + color: MAPEO_BLUE + }, + loaderContainer: { + width: '100%', + marginTop: '10%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center' + }, + closeButton: { + color: MAPEO_BLUE + } +})) diff --git a/src/renderer/components/MapFilter/MapView/MapPreviewCard.js b/src/renderer/components/MapFilter/MapView/MapPreviewCard.js new file mode 100644 index 000000000..0c565795b --- /dev/null +++ b/src/renderer/components/MapFilter/MapView/MapPreviewCard.js @@ -0,0 +1,73 @@ +import { Typography, makeStyles, useTheme } from '@material-ui/core' +import React from 'react' +import ReactMapboxGl from 'react-mapbox-gl' + +import { MAPBOX_ACCESS_TOKEN } from '../../../../../config' + +export const MapboxPreview = ReactMapboxGl({ + accessToken: MAPBOX_ACCESS_TOKEN, + interactive: false, + injectCSS: false +}) + +export const MapPreviewCard = ({ onClick, selected, styleUrl, title }) => { + const classes = useStyles() + const theme = useTheme() + + return ( + <> + + + ) +} + +const useStyles = makeStyles(theme => ({ + container: { + alignItems: 'center', + display: 'flex', + flexDirection: 'column', + cursor: 'pointer', + backgroundColor: 'transparent', + border: 'none' + }, + inner: { + borderRadius: 12, + borderStyle: 'solid', + marginBottom: 10, + borderWidth: 4, + overflow: 'hidden' + }, + thumbnail: { + height: 80, + width: 80, + cursor: 'pointer', + '& .mapboxgl-control-container': { + display: 'none' + } + }, + title: { + textAlign: 'center', + fontSize: 16, + maxWidth: 80 + } +})) diff --git a/src/renderer/components/MapFilter/MapView/MapView.js b/src/renderer/components/MapFilter/MapView/MapView.js index c18d7d6cf..7b42de21b 100644 --- a/src/renderer/components/MapFilter/MapView/MapView.js +++ b/src/renderer/components/MapFilter/MapView/MapView.js @@ -1,7 +1,16 @@ -import React from 'react' +// @ts-check + +import React, { useState } from 'react' import MapViewContent from './MapViewContent' import ViewWrapper from '../ViewWrapper' +import { Avatar, makeStyles } from '@material-ui/core' +import { LayersOutlined } from '@material-ui/icons' +import { BackgroundMapSelector } from './BackgroundMapSelector' +import { + useBackgroundMapStore, + useExperimentsFlagsStore +} from '../../../hooks/store' const MapView = ( { @@ -15,27 +24,77 @@ const MapView = ( }, ref ) => { + const [backgroundMapSelectorOpen, setBackgroundMapSelectorOpen] = useState( + false + ) + const backgroundMapsFlag = useExperimentsFlagsStore( + store => store.backgroundMaps + ) + const [mapStyle] = useBackgroundMapStore() + return ( - - {({ onClickObservation, filteredObservations, getPreset, getMedia }) => ( - + {backgroundMapsFlag ? ( + + setBackgroundMapSelectorOpen(selectorWasOpen => !selectorWasOpen) + } /> - )} - + ) : null} + + {({ + onClickObservation, + filteredObservations, + getPreset, + getMedia + }) => ( + + )} + + setBackgroundMapSelectorOpen(false)} + /> + + ) +} + +const useStyles = makeStyles(theme => ({ + avatar: { + backgroundColor: theme.palette.background.paper, + cursor: 'pointer' + }, + icon: { + color: theme.palette.common.black + } +})) + +const MapLayerButton = ({ onClick }) => { + const classes = useStyles() + return ( + + + ) } diff --git a/src/renderer/components/SettingsView/BackgroundMaps.js b/src/renderer/components/SettingsView/BackgroundMaps.js new file mode 100644 index 000000000..21325133e --- /dev/null +++ b/src/renderer/components/SettingsView/BackgroundMaps.js @@ -0,0 +1,80 @@ +// @ts-check +import * as React from 'react' +import { defineMessages, useIntl } from 'react-intl' +import Typography from '@material-ui/core/Typography' + +import { BackgroundMapInfo } from '../BackgroundMaps/BackgroundMapInfo' +import { SidePanel } from '../BackgroundMaps/SidePanel' +import { useMapStylesQuery } from '../../hooks/useMapStylesQuery' + +const m = defineMessages({ + // Title for description of offline maps + mapBackgroundTitle: 'Managing Map Backgrounds and Offline Areas', + // Text introducing the Background Maps feature + introText: + 'The Background Map in Mapeo is displayed on the observation screen and is used as a background for the observations you collect. This new pilot feature allows you to add your own custom maps and switch between multiple maps. Background Maps is currently an advanced feature - an existing map file in .mbtiles format is required for testing' +}) + +/** @typedef {import('../../hooks/useMapServerQuery').MapServerStyleInfo} MapServerStyleInfo */ + +/** + * @typedef BackgroundMapsProps + * @prop {()=>void} returnToSettings + */ +/** @param {BackgroundMapsProps} param */ +export const BackgroundMaps = ({ returnToSettings }) => { + const { formatMessage: t } = useIntl() + + /** @type {MapServerStyleInfo['id']|null} */ + const initialMapId = null + + const [viewingMapId, setViewingMapId] = React.useState( + /** @type {string | null} */ (initialMapId) + ) + + const { data } = useMapStylesQuery() + + function unsetViewingMap () { + setViewingMapId(null) + } + + const viewingMap = React.useMemo( + () => data && data.find(m => m.id === viewingMapId), + [data, viewingMapId] + ) + + return ( + <> + + + {!viewingMapId || !data ? ( +
+ + {t(m.mapBackgroundTitle)} + + + {t(m.introText)} +
+ ) : viewingMap ? ( + + ) : null} + + ) +} + +/** + * + * @param {number} kilobyte + * @returns {number} + */ +export const convertKbToMb = kilobyte => { + return kilobyte / 2 ** 20 +} diff --git a/src/renderer/components/SettingsView/ExperimentsMenu.js b/src/renderer/components/SettingsView/ExperimentsMenu.js new file mode 100644 index 000000000..6c2920e6a --- /dev/null +++ b/src/renderer/components/SettingsView/ExperimentsMenu.js @@ -0,0 +1,33 @@ +import React from 'react' +import { defineMessages } from 'react-intl' +import { Map as MapIcon } from '@material-ui/icons' +import { SettingsList } from './SettingsList' +import { useExperimentsFlagsStore } from '../../hooks/store' + +const m = defineMessages({ + backgroundMaps: 'Background Maps' +}) + +export const ExperiementsMenu = () => { + const [backgroundMaps, setBackgroundMaps] = useExperimentsFlagsStore( + store => [store.backgroundMaps, store.setBackgroundMapsFlag] + ) + + const toggleBackgroundMaps = () => { + setBackgroundMaps(!backgroundMaps) + } + + /** @type {import('./SettingsList').option[]} */ + const options = [ + { + id: 'BackgroundMaps', + icon: MapIcon, + label: m.backgroundMaps, + checked: backgroundMaps, + onClick: toggleBackgroundMaps, + type: 'toggle' + } + ] + + return +} diff --git a/src/renderer/components/SettingsView/SettingsItem.js b/src/renderer/components/SettingsView/SettingsItem.js new file mode 100644 index 000000000..05fef594e --- /dev/null +++ b/src/renderer/components/SettingsView/SettingsItem.js @@ -0,0 +1,99 @@ +import React from 'react' + +import { Typography, useTheme } from '@material-ui/core' +import ChevronRightIcon from '@material-ui/icons/ChevronRight' + +import { defineMessages, useIntl } from 'react-intl' +import styled from 'styled-components' + +const m = defineMessages({ + on: 'On', + off: 'Off' +}) + +export const SettingsItem = React.forwardRef( + ({ type, label, subtitle, icon: Icon, active, onClick, ...rest }, ref) => { + const theme = useTheme() + const { formatMessage: t } = useIntl() + + return ( + + + {Icon ? : null} + + + + {typeof label === 'string' ? label : t(label)} + + + {type === 'toggle' ? ( + + ) : ( + + )} + + + {type === 'menuItem' && ( + + )} + + ) + } +) + +const Subtitle = ({ label }) => { + const { formatMessage: t } = useIntl() + return typeof label === 'string' ? label : t(label) +} + +const ToggleSubtitle = ({ on }) => { + const { formatMessage: t } = useIntl() + + return t(on ? m.on : m.off) +} + +const Row = styled.div` + display: flex; + flex-direction: row; +` + +const Column = styled.div` + display: flex; + flex-direction: column; +` + +const WrapperRow = styled(Row)` + padding: 20px; + align-items: center; + justify-content: space-between; + cursor: pointer; +` + +const IconContainer = styled.div` + flex: 1; + padding: 10px 30px 10px 10px; + display: flex; + align-items: center; +` + +const TitleContainer = styled(Column)` + justify-content: flex-start; + flex: 8; +` diff --git a/src/renderer/components/SettingsView/SettingsList.js b/src/renderer/components/SettingsView/SettingsList.js new file mode 100644 index 000000000..c15b97242 --- /dev/null +++ b/src/renderer/components/SettingsView/SettingsList.js @@ -0,0 +1,150 @@ +import React from 'react' +import styled from 'styled-components' + +import { Switch, Typography, Paper, useTheme } from '@material-ui/core' +import ChevronRightIcon from '@material-ui/icons/ChevronRight' + +import { defineMessages, useIntl } from 'react-intl' + +const m = defineMessages({ + on: 'On', + off: 'Off', +}) + +/** + * @typedef {object} option + * @prop {string} option.id + * @prop {boolean} option.checked + * @prop {() => void} option.onClick + * @prop {string | import('react-intl').MessageDescriptor} option.label + * @prop {string | import('react-intl').MessageDescriptor} option.subtitle + * @prop {'menuItem' | 'toggle'} option.type + * @prop {import('@material-ui/core/OverridableComponent').OverridableComponent>} tab.icon + * @typedef SettingsListProps + * @prop {option[]} options + */ + +/** @param {SettingsListProps} props */ +export const SettingsList = ({ options }) => { + return ( + + {options + ? options.map(({ id, onClick, ...rest }) => { + return + }) + : null} + + ) +} + +/** + * @typedef SettingsItemProps + * @prop {'menuItem' | 'toggle'} type + * @prop {string | import('react-intl').MessageDescriptor} label + * @prop {string | import('react-intl').MessageDescriptor} subtitle + * @prop {import('@material-ui/core/OverridableComponent').OverridableComponent>} icon + * @prop {boolean} checked + * @prop {boolean} active + * @prop {string} option.id + * @prop {() => void} onClick + */ + +export const SettingsItem = React.forwardRef( + /** @param {SettingsItemProps} props */ + ({ type, label, subtitle, icon: Icon, active, checked, onClick, ...rest }, ref) => { + const theme = useTheme() + const { formatMessage: t } = useIntl() + + return ( + + {Icon ? : null} + + + {typeof label === 'string' ? label : t(label)} + + + {type === 'toggle' ? : } + + + {type === 'menuItem' && } + {type === 'toggle' && } + + ) + }, +) + +const Stack = styled(Paper)` + display: flex; + flex-direction: column; + gap: ${props => props.gap || '10px'}; + + &.MuiPaper-rounded { + border-radius: 0; + } +` +const Subtitle = ({ label }) => { + const { formatMessage: t } = useIntl() + if (!label) return null + + return typeof label === 'string' ? label : t(label) +} + +const ToggleSubtitle = ({ on }) => { + const { formatMessage: t } = useIntl() + + return t(on ? m.on : m.off) +} + +const Row = styled.div` + display: flex; + flex-direction: row; +` + +const Column = styled.div` + display: flex; + flex-direction: column; +` + +const WrapperRow = styled(Row)` + padding: 20px; + align-items: center; + justify-content: space-between; + cursor: pointer; + + .MuiSwitch-root { + margin-left: 30px; + } +` + +const IconContainer = styled.div` + flex: 1; + padding: 10px 30px 10px 10px; + display: flex; + align-items: center; +` + +const TitleContainer = styled(Column)` + justify-content: flex-start; + flex: 8; + + * { + user-select: none; + } +` diff --git a/src/renderer/components/SettingsView/SettingsMenu.js b/src/renderer/components/SettingsView/SettingsMenu.js index 7bad924ea..5a85b0be7 100644 --- a/src/renderer/components/SettingsView/SettingsMenu.js +++ b/src/renderer/components/SettingsView/SettingsMenu.js @@ -1,18 +1,18 @@ import * as React from 'react' import Tabs from '@material-ui/core/Tabs' import Tab from '@material-ui/core/Tab' -import { useIntl } from 'react-intl' -import ChevronRightIcon from '@material-ui/icons/ChevronRight' import styled from 'styled-components' -import { Paper, Typography, useTheme } from '@material-ui/core' +import { Paper } from '@material-ui/core' +import { SettingsItem } from './SettingsList' -/** @typedef {'AboutMapeo'} tabId */ +/** @typedef {'AboutMapeo'| 'Experiments' | 'BackgroundMaps'} tabId */ /** * @typedef {object} tab * @prop {tabId} tab.tabId * @prop {string | import('react-intl').MessageDescriptor} tab.label * @prop {string | import('react-intl').MessageDescriptor} tab.subtitle + * @prop {'menuItem' | 'toggle'} tab.type * @prop {import('@material-ui/core/OverridableComponent').OverridableComponent>} tab.icon * @typedef SettingsMenuProps * @prop {tab[]} tabs @@ -25,7 +25,7 @@ export const SettingsMenu = ({ tabs, currentTab, onTabChange }) => { return ( { ) } -const RenderTab = React.forwardRef( - ( - { tab: { icon: Icon, label, subtitle }, active, children, ...rest }, - ref - ) => { - const { formatMessage: t } = useIntl() - const theme = useTheme() - - return ( - - - {Icon ? : null} - - - - {typeof label === 'string' ? label : t(label)} - - - {typeof subtitle === 'string' ? subtitle : t(subtitle)} - - - - - ) - } +/** + * @typedef RenderTabProps + * @prop {tab} tab + * @prop {number} currentTab + * @prop {(e: React.Dispatch) => number} onTabChange + */ +/** @param {RenderTabProps} props */ +export const RenderTab = React.forwardRef( + ({ tab: { icon, label, subtitle, type }, ...rest }, ref) => ( + + ) ) const StyledTabs = styled(Tabs)` @@ -108,31 +83,3 @@ const StyledTabs = styled(Tabs)` max-width: 100%; } ` - -const Row = styled.div` - display: flex; - flex-direction: row; -` - -const Column = styled.div` - display: flex; - flex-direction: column; -` - -const WrapperRow = styled(Row)` - padding: 20px; - align-items: center; - justify-content: space-between; -` - -const IconContainer = styled.div` - flex: 1; - padding: 10px 30px 10px 10px; - display: flex; - align-items: center; -` - -const TitleContainer = styled(Column)` - justify-content: flex-start; - flex: 8; -` diff --git a/src/renderer/components/SettingsView/index.js b/src/renderer/components/SettingsView/index.js index 8ccca6ef3..e257e889f 100644 --- a/src/renderer/components/SettingsView/index.js +++ b/src/renderer/components/SettingsView/index.js @@ -1,34 +1,68 @@ import React, { useState } from 'react' import InfoIcon from '@material-ui/icons/InfoOutlined' +import FlagIcon from '@material-ui/icons/Flag' +import MapIcon from '@material-ui/icons/Map' import { SettingsMenu } from './SettingsMenu' import { defineMessages } from 'react-intl' import { AboutMapeoMenu } from './AboutMapeo' import styled from 'styled-components' +import { ExperiementsMenu } from './ExperimentsMenu' +import { useExperimentsFlagsStore } from '../../hooks/store' +import { BackgroundMaps } from './BackgroundMaps' const m = defineMessages({ aboutMapeo: 'About Mapeo', aboutMapeoSubtitle: 'Version and build number', + experiments: 'Experiments', + experimentsSubtitle: 'Turn on experimental new features', + backgroundMaps: 'Background maps' }) -const tabs = [ - { - /** @type {import('./SettingsMenu').tabId} */ - tabId: 'AboutMapeo', - icon: InfoIcon, - label: m.aboutMapeo, - subtitle: m.aboutMapeoSubtitle, - }, -] - export const SettingsView = () => { - /** @type {import('./SettingsMenu').tabId | null} */ - const initialMenuState = /** {const} */ null + const backgroundMaps = useExperimentsFlagsStore(store => store.backgroundMaps) + const tabs = /** @type {import('./SettingsMenu').tabs} */ [ + { + tabId: 'AboutMapeo', + icon: InfoIcon, + label: m.aboutMapeo, + subtitle: m.aboutMapeoSubtitle + }, + { + tabId: 'Experiments', + icon: FlagIcon, + label: m.experiments, + subtitle: m.experimentsSubtitle + }, + ...(backgroundMaps + ? [ + { + tabId: 'BackgroundMaps', + icon: MapIcon, + label: m.backgroundMaps + } + ] + : []) + ] + const initialMenuState = /** {null | number} */ null const [menuItem, setMenuItem] = useState(initialMenuState) return ( - - {menuItem === 'AboutMapeo' && } + {menuItem === 'BackgroundMaps' ? ( + setMenuItem(initialMenuState)} + /> + ) : ( + <> + + {menuItem === 'AboutMapeo' && } + {menuItem === 'Experiments' && } + + )} ) } @@ -36,4 +70,5 @@ export const SettingsView = () => { const Container = styled.div` width: 100%; display: flex; + height: 100%; ` diff --git a/src/renderer/components/dialogs/ImportMapStyle.js b/src/renderer/components/dialogs/ImportMapStyle.js new file mode 100644 index 000000000..d69e1d60e --- /dev/null +++ b/src/renderer/components/dialogs/ImportMapStyle.js @@ -0,0 +1,132 @@ +// @ts-check +import * as React from 'react' +import { + Button, + CardActionArea, + makeStyles, + Typography +} from '@material-ui/core' +import Dialog from '@material-ui/core/Dialog' +import DialogTitle from '@material-ui/core/DialogTitle' +import DialogActions from '@material-ui/core/DialogActions' +import DialogContent from '@material-ui/core/DialogContent' +import { defineMessages, useIntl } from 'react-intl' +import { useMapServerMutation } from '../../hooks/useMapServerMutation' +import Card from '@material-ui/core/Card' +import CardContent from '@material-ui/core/CardContent' +import SystemUpdateAltIcon from '@material-ui/icons/SystemUpdateAlt' +import CloseIcon from '@material-ui/icons/Close' +import IconButton from '@material-ui/core/IconButton' +import { ipcRenderer } from 'electron' + +const m = defineMessages({ + // Title of screen used to add a new background map + addMap: 'Add Map Background', + // button to cancel the import of a background map + cancel: 'Cancel', + // Title for import errot pop up dialog, + importErrorTitle: 'Background Maps Import Error', + // Description of map import error + importErrorDescription: + 'There was an error importing the background maps. Please try again. Error message:', + // Label of 'Import File' button + importFile: 'Import File' +}) + +/** + * @typedef ImportMapStyleDialogProps + * @prop {boolean} open + * @prop {()=>void} close + * @prop {()=>void} refetch + */ + +/** @param {ImportMapStyleDialogProps} importMapStyleDialogProps */ +export const ImportMapStyleDialog = ({ open, close, refetch }) => { + const { formatMessage: t } = useIntl() + const mutation = useMapServerMutation('post', '/tilesets/import') + + const classes = useStyles() + + async function selectMbTileFile () { + const result = await ipcRenderer.invoke('select-mb-tile-file') + + if (result.canceled) return + if (!result.filePaths || !result.filePaths.length) return + + try { + const filePath = result.filePaths[0] + + await mutation.mutateAsync({ filePath }) + close() + refetch() + } catch (err) { + close() + onError(err) + } + + async function onError (err) { + console.log({ err }) + ipcRenderer.send( + 'show-error-dialog', + t(m.importErrorTitle), + `${t(m.importErrorDescription)} ${err}` + ) + } + } + + return ( + + + + {t(m.addMap)} + + + + + + + + + + + + {t(m.importFile)} + + + {'(.mbtiles)'} + + + + + + + + + + ) +} + +const useStyles = makeStyles({ + titleContainer: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between' + }, + title: { + fontSize: 24, + fontWeight: 500 + } +}) diff --git a/src/renderer/hooks/store.js b/src/renderer/hooks/store.js index aebcc11c7..fa412065e 100644 --- a/src/renderer/hooks/store.js +++ b/src/renderer/hooks/store.js @@ -2,6 +2,18 @@ import { create } from 'zustand' import { persist, createJSONStorage } from 'zustand/middleware' import store from '../../persist-store' +import { DEFAULT_MAP } from './useMapStylesQuery' + +/** + * @typedef { { + * id: string, + * url: string, + * bytesStored: number, + * name: import('react-intl').MessageDescriptor | string, + * isDefault?: boolean, + * isImporting?: boolean + * }} MapStyle + */ /** * @type {import('zustand/middleware').StateStorage} @@ -52,17 +64,23 @@ const experimentsFlagsStoreSlice = (set, get) => ({ /** * @typedef {{ - * mapStyle: string, - * setMapStyle: (mapStyle: string) => void + * mapStyleLegacy: MapStyle, + * mapStyleMapServer: MapStyle, + * setMapStyleLegacy: (mapStyle:MapStyle) => void, + * setMapStyleServer: (mapStyle:MapStyle) => void * }} BackgroundMapStoreSlice */ /** * @type {import('zustand').StateCreator} */ -const backgroundMapStoreSlice = (set, get) => ({ - mapStyle: '', - setMapStyle: mapStyle => set({ mapStyle }) -}) +const backgroundMapStoreSlice = (set, get) => { + return { + mapStyleLegacy: DEFAULT_MAP, + mapStyleMapServer: DEFAULT_MAP, + setMapStyleLegacy: mapStyle => set({ mapStyleLegacy: mapStyle }), + setMapStyleServer: mapStyle => set({ mapStyleMapServer: mapStyle }) + } +} /** * @typedef {{ @@ -82,7 +100,8 @@ export const useExperimentsFlagsStore = createPersistedStore( experimentsFlagsStoreSlice, 'experiments-flags' ) -export const useBackgroundMapStore = createPersistedStore( + +const useBackgroundMapState = createPersistedStore( backgroundMapStoreSlice, 'background-maps' ) @@ -90,3 +109,21 @@ export const usePersistedUiStore = createPersistedStore( persistedUiStoreSlice, 'ui' ) + +/** + * + * @returns {[MapStyle, (mapStyle: MapStyle) => void]} + */ +export const useBackgroundMapStore = () => { + const backgroundMapsEnabled = useExperimentsFlagsStore( + store => store.backgroundMaps + ) + + const [mapStyle, setMapStyle] = useBackgroundMapState(store => + backgroundMapsEnabled + ? [store.mapStyleMapServer, store.setMapStyleServer] + : [store.mapStyleLegacy, store.setMapStyleLegacy] + ) + + return [mapStyle, setMapStyle] +} diff --git a/src/renderer/hooks/useMapStylesQuery.js b/src/renderer/hooks/useMapStylesQuery.js index 70e851c8a..9d1d47f5d 100644 --- a/src/renderer/hooks/useMapStylesQuery.js +++ b/src/renderer/hooks/useMapStylesQuery.js @@ -18,29 +18,58 @@ const m = defineMessages({ offlineBackgroundMapName: 'Offline Map' }) -export const useMapStylesQuery = () => { +export const DEFAULT_MAP = { + id: DEFAULT_MAP_ID, + url: ONLINE_STYLE_URL, + bytesStored: 0, + name: m.defaultBackgroundMapName, + isImporting: false, + isDefault: true +} + +export const useMapStylesQuery = (enabled = true) => { const backgroundMapsEnabled = useExperimentsFlagsStore( store => store.backgroundMaps ) - const legacyStyleQueryResult = useLegacyMapStyleQuery(!backgroundMapsEnabled) + const legacyStyleQueryResult = useLegacyMapStyleQuery( + !backgroundMapsEnabled && enabled + ) const mapStylesQueryResult = useMapServerQuery( '/styles', - backgroundMapsEnabled + backgroundMapsEnabled && enabled ) - return backgroundMapsEnabled ? mapStylesQueryResult : legacyStyleQueryResult + const queryResult = backgroundMapsEnabled + ? { + data: [ + DEFAULT_MAP, + ...(mapStylesQueryResult?.data ? mapStylesQueryResult?.data : []) + ], + isLoading: mapStylesQueryResult.isLoading, + refetch: mapStylesQueryResult.refetch + } + : { + data: !legacyStyleQueryResult.data + ? [DEFAULT_MAP] + : legacyStyleQueryResult.data, + isLoading: legacyStyleQueryResult.isLoading, + refetch: legacyStyleQueryResult.refetch + } + + return queryResult } -export const useLegacyMapStyleQuery = enabled => { +const useLegacyMapStyleQuery = enabled => { const { formatMessage: t } = useIntl() const queryResult = useQuery({ queryKey: ['getLegacyMapStyle'], - queryFn: () => { + queryFn: async () => { try { // This checks whether an offline style is available - api.getMapStyle('default') + await api.getMapStyle('default') + return [ { id: CUSTOM_MAP_ID, @@ -51,15 +80,7 @@ export const useLegacyMapStyleQuery = enabled => { } ] } catch { - return [ - { - id: DEFAULT_MAP_ID, - url: ONLINE_STYLE_URL, - bytesStored: 0, - name: t(m.defaultBackgroundMapName), - isImporting: false - } - ] + return [DEFAULT_MAP] } }, enabled