diff --git a/messages/renderer/en.json b/messages/renderer/en.json index 66cf763..3d0520c 100644 --- a/messages/renderer/en.json +++ b/messages/renderer/en.json @@ -5,6 +5,27 @@ "components.OnboardingTopMenu.step": { "message": "Step {number}" }, + "emptyState.inviteDevices": { + "message": "Invite Devices" + }, + "emptyState.noObservationsFound": { + "message": "No Observations Found" + }, + "mapMain.errorLoading": { + "message": "Oops! Error loading data." + }, + "mapMain.loading": { + "message": "Loading..." + }, + "mapMain.unnamedProject": { + "message": "Unnamed Project" + }, + "observationListView.button.exchange": { + "message": "View Exchange" + }, + "observationListView.button.team": { + "message": "View Team" + }, "screens.CreateJoinProjectScreen.askToJoin": { "message": "Ask a monitoring coordinator to join their Project." }, @@ -98,6 +119,13 @@ "screens.JoinProjectScreen.title": { "message": "Join" }, + "screens.Observation.ObservationView.observation": { + "description": "Default name of observation with no matching preset", + "message": "Observation" + }, + "screens.ObservationList.TrackListItem.Track": { + "message": "Track" + }, "screens.OnboardingPrivacyPolicy.permissionsTitle": { "message": "Current Permissions" }, diff --git a/src/renderer/src/Theme.ts b/src/renderer/src/Theme.ts index 62dc11b..cfe2ff2 100644 --- a/src/renderer/src/Theme.ts +++ b/src/renderer/src/Theme.ts @@ -1,15 +1,25 @@ import { createTheme } from '@mui/material/styles' import { + ALMOST_BLACK, COMAPEO_BLUE, + DARKER_ORANGE, DARK_COMAPEO_BLUE, + DARK_GREY, DARK_ORANGE, GREEN, LIGHT_COMAPEO_BLUE, ORANGE, RED, + WHITE, } from './colors' +declare module '@mui/material/Button' { + interface ButtonPropsVariantOverrides { + darkOrange: true + } +} + const theme = createTheme({ typography: { fontFamily: 'Rubik, sans-serif', @@ -44,6 +54,10 @@ const theme = createTheme({ }, }, palette: { + text: { + primary: ALMOST_BLACK, + secondary: DARK_GREY, + }, primary: { main: COMAPEO_BLUE, dark: DARK_COMAPEO_BLUE, @@ -59,9 +73,24 @@ const theme = createTheme({ error: { main: RED, }, + background: { + default: WHITE, + }, }, components: { MuiButton: { + variants: [ + { + props: { variant: 'darkOrange' }, + style: { + backgroundColor: DARK_ORANGE, + color: WHITE, + '&:hover': { + backgroundColor: DARKER_ORANGE, + }, + }, + }, + ], defaultProps: { variant: 'contained', }, diff --git a/src/renderer/src/colors.ts b/src/renderer/src/colors.ts index fad1f5a..da62090 100644 --- a/src/renderer/src/colors.ts +++ b/src/renderer/src/colors.ts @@ -3,14 +3,17 @@ export const DARK_COMAPEO_BLUE = '#050F77' export const LIGHT_COMAPEO_BLUE = '#CCE0FF' export const CORNFLOWER_BLUE = '#80A0FF' +export const DARK_BLUE = '#000033' export const BLUE_GREY = '#CCCCD6' export const DARK_GREY = '#757575' export const VERY_LIGHT_GREY = '#ededed' export const ORANGE = '#FF9933' export const DARK_ORANGE = '#E86826' +export const DARKER_ORANGE = '#D95F28' export const GREEN = '#59A553' export const RED = '#D92222' export const BLACK = '#000000' +export const ALMOST_BLACK = '#333333' export const WHITE = '#ffffff' diff --git a/src/renderer/src/components/Button.tsx b/src/renderer/src/components/Button.tsx index e014802..0a40994 100644 --- a/src/renderer/src/components/Button.tsx +++ b/src/renderer/src/components/Button.tsx @@ -3,18 +3,20 @@ import { type MouseEventHandler, type PropsWithChildren, } from 'react' -import { Button as MuiButton } from '@mui/material' +import { Button as MuiButton, type ButtonProps } from '@mui/material' type CustomButtonProps = PropsWithChildren<{ name?: string className?: string - color?: 'primary' | 'secondary' | 'success' | 'error' + color?: ButtonProps['color'] size?: 'medium' | 'large' | 'fullWidth' testID?: string - variant?: 'contained' | 'outlined' | 'text' + variant?: 'contained' | 'outlined' | 'text' | 'darkOrange' style?: CSSProperties onClick?: MouseEventHandler disabled?: boolean + startIcon?: React.ReactNode + endIcon?: React.ReactNode }> export const Button = ({ @@ -26,11 +28,13 @@ export const Button = ({ style, disabled, className, + startIcon, ...props }: CustomButtonProps) => { const propsBasedOnSize = size === 'fullWidth' ? { fullWidth: true } : { size } return ( { + const { formatMessage: t } = useIntl() + const name = preset + ? t({ id: `presets.${preset.docId}.name`, defaultMessage: preset.name }) + : t(m.observation) + + return {name} +} diff --git a/src/renderer/src/components/Observations/EmptyState.tsx b/src/renderer/src/components/Observations/EmptyState.tsx new file mode 100644 index 0000000..a4766e7 --- /dev/null +++ b/src/renderer/src/components/Observations/EmptyState.tsx @@ -0,0 +1,88 @@ +import { styled } from '@mui/material/styles' +import { defineMessages, useIntl } from 'react-intl' + +import { ALMOST_BLACK, BLUE_GREY, VERY_LIGHT_GREY } from '../../colors' +import AddPersonIcon from '../../images/AddPerson.svg' +import EmptyStateImage from '../../images/empty_state.png' +import { Button } from '../Button' +import { Text } from '../Text' + +const m = defineMessages({ + inviteDevices: { + id: 'emptyState.inviteDevices', + defaultMessage: 'Invite Devices', + }, + noObservationsFound: { + id: 'emptyState.noObservationsFound', + defaultMessage: 'No Observations Found', + }, +}) + +const Container = styled('div')({ + display: 'flex', + flexDirection: 'column', + padding: '25px 20px', +}) + +const DividerLine = styled('div')({ + width: '100%', + height: 1, + backgroundColor: VERY_LIGHT_GREY, +}) + +const Circle = styled('div')({ + width: 260, + height: 260, + borderRadius: '50%', + backgroundColor: 'rgba(0, 102, 255, 0.1)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', +}) + +const StyledImage = styled('img')({ + width: 120, + height: 120, +}) + +const LowerContainer = styled('div')({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + height: '75%', + justifyContent: 'center', + gap: 18, +}) + +type EmptyStateProps = { + onInviteDevices?: () => void +} + +export function EmptyState({ onInviteDevices }: EmptyStateProps) { + const { formatMessage } = useIntl() + + return ( + <> + + + + + + + + + {formatMessage(m.noObservationsFound)} + + + ) +} diff --git a/src/renderer/src/components/Observations/ObservationListItem.tsx b/src/renderer/src/components/Observations/ObservationListItem.tsx new file mode 100644 index 0000000..230744e --- /dev/null +++ b/src/renderer/src/components/Observations/ObservationListItem.tsx @@ -0,0 +1,84 @@ +import type { Observation } from '@comapeo/schema' +import { styled } from '@mui/material/styles' +import { FormattedDate, FormattedTime } from 'react-intl' + +import { VERY_LIGHT_GREY } from '../../colors' +import { useActiveProjectIdStoreState } from '../../contexts/ActiveProjectIdProvider' +import { useObservationWithPreset } from '../../hooks/useObservationWithPreset' +import { FormattedPresetName } from '../FormattedData' +import { PresetCircleIcon } from '../PresetCircleIcon' +import { Text } from '../Text' + +type Props = { + observation: Observation + onClick?: () => void +} + +const Container = styled('div')({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + borderBottom: `1px solid ${VERY_LIGHT_GREY}`, + padding: '10px 20px', + cursor: 'pointer', + width: '100%', + '&:hover': { + backgroundColor: '#f9f9f9', + }, +}) + +const TextContainer = styled('div')({ + flex: 1, + display: 'flex', + flexDirection: 'column', +}) + +const PhotoContainer = styled('img')({ + width: 48, + height: 48, + borderRadius: 6, + objectFit: 'cover', +}) + +export function ObservationListItem({ observation, onClick }: Props) { + const projectId = useActiveProjectIdStoreState((s) => s.activeProjectId) + // TODO: Ideally, the fallback shouldn't be necessary + const preset = useObservationWithPreset(observation, projectId ?? '') + const createdAt = observation.createdAt + ? new Date(observation.createdAt) + : new Date() + + const photoAttachment = observation.attachments.find( + (att) => att.type === 'photo', + ) + + return ( + + + + + + + + {', '} + + + + {photoAttachment ? ( + + ) : ( + + )} + + ) +} diff --git a/src/renderer/src/components/Observations/ObservationListView.tsx b/src/renderer/src/components/Observations/ObservationListView.tsx new file mode 100644 index 0000000..2f19567 --- /dev/null +++ b/src/renderer/src/components/Observations/ObservationListView.tsx @@ -0,0 +1,128 @@ +import type { Observation, Track } from '@comapeo/schema' +import { styled } from '@mui/material/styles' +import { defineMessages, useIntl } from 'react-intl' + +import { ALMOST_BLACK, VERY_LIGHT_GREY, WHITE } from '../../colors' +import AddPersonIcon from '../../images/AddPerson.svg' +import LightningIcon from '../../images/Lightning.svg' +import { Button } from '../Button' +import { ObservationListItem } from './ObservationListItem' +import { TrackListItem } from './TrackListItem' + +const m = defineMessages({ + exchange: { + id: 'observationListView.button.exchange', + defaultMessage: 'View Exchange', + }, + team: { + id: 'observationListView.button.team', + defaultMessage: 'View Team', + }, +}) + +const Container = styled('div')({ + display: 'flex', + flexDirection: 'column', + flex: 1, + overflow: 'hidden', + backgroundColor: WHITE, +}) + +const ContentWrapper = styled('div')({ + padding: 20, + borderBottom: `1px solid ${VERY_LIGHT_GREY}`, +}) + +const ButtonsRow = styled('div')({ + display: 'flex', + gap: 10, +}) + +const ListContainer = styled('div')({ + overflowY: 'auto', + flex: 1, + margin: 0, + padding: '0 0 20px 0', +}) + +type CombinedData = Observation | Track + +type ObservationListViewProps = { + projectName?: string + combinedData: Array + onViewExchange?: () => void + onViewTeam?: () => void + onSelectObservation?: (obsId: string) => void + onSelectTrack?: (trackId: string) => void + onEditProjectName?: () => void +} + +export function ObservationListView({ + combinedData, + onViewExchange, + onViewTeam, + onSelectObservation, + onSelectTrack, +}: ObservationListViewProps) { + const { formatMessage } = useIntl() + + return ( + + + + + + + + + + {combinedData.map((item) => ( +
  • + {item.schemaName === 'observation' ? ( + onSelectObservation(item.docId) + : undefined + } + /> + ) : ( + onSelectTrack(item.docId) : undefined + } + /> + )} +
  • + ))} +
    +
    + ) +} diff --git a/src/renderer/src/components/Observations/ProjectHeader.tsx b/src/renderer/src/components/Observations/ProjectHeader.tsx new file mode 100644 index 0000000..b6f4e72 --- /dev/null +++ b/src/renderer/src/components/Observations/ProjectHeader.tsx @@ -0,0 +1,34 @@ +import { styled } from '@mui/material/styles' + +import PencilIcon from '../../images/pencil.png' +import { Text } from '../Text' + +const TitleRow = styled('div')({ + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + padding: '0 20px', +}) + +const PencilImg = styled('img')({ + width: 20, + height: 20, + cursor: 'pointer', +}) + +export function ProjectHeader({ + projectName, + onEdit, +}: { + projectName: string + onEdit: () => void +}) { + return ( + + + {projectName} + + + + ) +} diff --git a/src/renderer/src/components/Observations/TrackListItem.tsx b/src/renderer/src/components/Observations/TrackListItem.tsx new file mode 100644 index 0000000..269ecb5 --- /dev/null +++ b/src/renderer/src/components/Observations/TrackListItem.tsx @@ -0,0 +1,68 @@ +import type { Track } from '@comapeo/schema' +import { styled } from '@mui/material/styles' +import { + FormattedDate, + FormattedTime, + defineMessages, + useIntl, +} from 'react-intl' + +import { VERY_LIGHT_GREY } from '../../colors' +import TrackIcon from '../../images/Track.svg' +import { Text } from '../Text' + +const m = defineMessages({ + track: { + id: 'screens.ObservationList.TrackListItem.Track', + defaultMessage: 'Track', + }, +}) + +type Props = { + track: Track + projectId?: string + onClick?: () => void +} + +const Container = styled('div')({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + borderBottom: `1px solid ${VERY_LIGHT_GREY}`, + padding: '10px 20px', + cursor: 'pointer', + width: '100%', + '&:hover': { + backgroundColor: '#f9f9f9', + }, +}) + +const TextContainer = styled('div')({ + flex: 1, + display: 'flex', + flexDirection: 'column', +}) + +export function TrackListItem({ track, onClick }: Props) { + const createdAt = track.createdAt ? new Date(track.createdAt) : new Date() + const { formatMessage } = useIntl() + + return ( + + + {formatMessage(m.track)} + + + {', '} + + + + + + ) +} diff --git a/src/renderer/src/components/Onboarding/OnboardingTopMenu.tsx b/src/renderer/src/components/Onboarding/OnboardingTopMenu.tsx index 0a0464b..1dbcdd8 100644 --- a/src/renderer/src/components/Onboarding/OnboardingTopMenu.tsx +++ b/src/renderer/src/components/Onboarding/OnboardingTopMenu.tsx @@ -35,10 +35,8 @@ const Step = styled('div')<{ active: boolean }>(({ active }) => ({ backgroundColor: active ? WHITE : 'transparent', - color: active ? BLACK : BLUE_GREY, padding: '12px 32px', borderRadius: 20, - fontWeight: active ? 'bold' : 'normal', whiteSpace: 'nowrap', cursor: 'default', })) @@ -93,7 +91,11 @@ export function OnboardingTopMenu({ {[1, 2, 3].map((step) => ( - + {formatMessage(m.step, { number: step })} diff --git a/src/renderer/src/components/Onboarding/PrivacyPolicy/index.tsx b/src/renderer/src/components/Onboarding/PrivacyPolicy/index.tsx index 630bc82..716ee7b 100644 --- a/src/renderer/src/components/Onboarding/PrivacyPolicy/index.tsx +++ b/src/renderer/src/components/Onboarding/PrivacyPolicy/index.tsx @@ -2,7 +2,13 @@ import React from 'react' import { styled } from '@mui/material/styles' import { useIntl } from 'react-intl' -import { BLUE_GREY, DARK_GREY, VERY_LIGHT_GREY, WHITE } from '../../../colors' +import { + BLUE_GREY, + DARK_BLUE, + DARK_GREY, + VERY_LIGHT_GREY, + WHITE, +} from '../../../colors' import BarChart from '../../../images/BarChart.svg' import BustInSilhouette from '../../../images/BustInSilhouette.svg' import Wrench from '../../../images/Wrench.svg' @@ -36,6 +42,7 @@ const Subheader = styled(Text)({ fontSize: 24, fontWeight: 'bold', marginBottom: 12, + color: DARK_BLUE, }) const ContentBox = styled('div')({ width: '70%', diff --git a/src/renderer/src/components/PresetCircleIcon.tsx b/src/renderer/src/components/PresetCircleIcon.tsx new file mode 100644 index 0000000..a0c6b40 --- /dev/null +++ b/src/renderer/src/components/PresetCircleIcon.tsx @@ -0,0 +1,103 @@ +import { useIconUrl } from '@comapeo/core-react' +import CircularProgress from '@mui/material/CircularProgress' +import { styled } from '@mui/material/styles' + +import { hexToRgba } from '../lib/utils' + +type IconSize = 'small' | 'medium' | 'large' + +const sizeMap: Record = { + small: 24, + medium: 35, + large: 50, +} + +const Circle = styled('div')<{ radius?: number; borderColor?: string }>( + ({ radius = 25, borderColor = '#000' }) => ({ + width: radius * 2, + height: radius * 2, + borderRadius: '50%', + boxShadow: `0px 2px 5px ${hexToRgba(borderColor, 0.3)}`, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }), +) + +const IconContainer = styled('div')<{ + radius?: number + borderColor?: string +}>(({ radius = 25, borderColor }) => ({ + width: radius * 2, + height: radius * 2, + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + borderWidth: 3, + borderStyle: 'solid', + borderColor: borderColor || '#000', +})) + +type Props = { + projectId?: string + iconId?: string + borderColor?: string + size?: IconSize +} + +export function PresetCircleIcon({ + projectId, + iconId, + borderColor, + size = 'medium', +}: Props) { + const radius = size === 'small' ? 15 : size === 'large' ? 35 : 25 + const { + data: iconUrl, + error, + isRefetching, + } = projectId && iconId + ? useIconUrl({ + projectId, + iconId, + mimeType: 'image/png', + pixelDensity: 1, + size: 'medium', + }) + : { data: undefined, error: undefined, isRefetching: false } + + if (!projectId || !iconId || error || !iconUrl) { + return ( + + 📍 + + ) + } + + if (isRefetching) { + return ( + + + + ) + } + + return ( + + + Preset Icon + + + ) +} diff --git a/src/renderer/src/components/Tabs.tsx b/src/renderer/src/components/Tabs.tsx index 3d3be44..921716e 100644 --- a/src/renderer/src/components/Tabs.tsx +++ b/src/renderer/src/components/Tabs.tsx @@ -47,7 +47,7 @@ export const Tabs = () => { } - value={'/tab1'} + value={'/main'} /> {/* This is needed to properly space the items. Originally used a div, but was causing console errors as the Parent component passes it props, which were invalid for non-tab components */} diff --git a/src/renderer/src/components/Text.tsx b/src/renderer/src/components/Text.tsx index 7b09161..cf4d0fa 100644 --- a/src/renderer/src/components/Text.tsx +++ b/src/renderer/src/components/Text.tsx @@ -1,13 +1,28 @@ -import { type CSSProperties, type PropsWithChildren } from 'react' +import { + type CSSProperties, + type ComponentProps, + type PropsWithChildren, +} from 'react' import Typography from '@mui/material/Typography' import { type Variant } from '@mui/material/styles/createTypography' -type Kind = 'title' | 'subtitle' | 'body' +type Kind = 'title' | 'subtitle' | 'body' | 'caption' + +type TextColor = 'primary' | 'secondary' | 'disabled' const kindToVariant: { [k in Kind]: Variant } = { title: 'h1', subtitle: 'subtitle1', body: 'body1', + caption: 'caption', +} as const + +const textColorToTypographyColor: { + [c in TextColor]: ComponentProps['color'] +} = { + primary: 'textPrimary', + secondary: 'textSecondary', + disabled: 'textDisabled', } as const type BaseProps = PropsWithChildren<{ @@ -24,6 +39,7 @@ type BaseProps = PropsWithChildren<{ }> type TextProps = BaseProps & { + color?: TextColor kind?: Kind } @@ -33,11 +49,13 @@ export function Text({ kind = 'body', style, underline, + color = 'primary', ...otherProps }: TextProps) { return ( ({ + mutationFn: async ({ projectId, count = 10 }) => { + const projectApi: MapeoProjectApi = await api.getProject(projectId) + const [deviceInfo, presets] = await Promise.all([ + api.getDeviceInfo(), + projectApi.preset.getMany(), + ]) + + const centerLon = -72.312023 + const centerLat = -10.38787 + const deltaDeg = 0.1 + + const bbox: BBox = [ + centerLon - deltaDeg, + centerLat - deltaDeg, + centerLon + deltaDeg, + centerLat + deltaDeg, + ] + + function randomLonLat( + bbox: [number, number, number, number], + ): [number, number] { + const [minLon, minLat, maxLon, maxLat] = bbox + const lon = Math.random() * (maxLon - minLon) + minLon + const lat = Math.random() * (maxLat - minLat) + minLat + return [lon, lat] + } + + const promises = [] + const notes = deviceInfo.name ? `Created by ${deviceInfo.name}` : null + + for (let i = 0; i < count; i++) { + const [lon, lat] = randomLonLat(bbox) + const now = new Date().toISOString() + const randomPreset = presets.at( + Math.floor(Math.random() * presets.length), + ) + + const observationValue = { + schemaName: 'observation' as const, + lon, + lat, + metadata: { + manualLocation: false, + position: { + timestamp: now, + mocked: false, + coords: { latitude: lat, longitude: lon }, + }, + }, + tags: { ...randomPreset!.tags, notes }, + attachments: [], + } + + promises.push(projectApi.observation.create(observationValue)) + } + + await Promise.all(promises) + }, + onSuccess: (_data, { projectId }) => { + queryClient.invalidateQueries({ queryKey: ['observations', projectId] }) + }, + }) +} diff --git a/src/renderer/src/hooks/useObservationWithPreset.ts b/src/renderer/src/hooks/useObservationWithPreset.ts new file mode 100644 index 0000000..93dfb62 --- /dev/null +++ b/src/renderer/src/hooks/useObservationWithPreset.ts @@ -0,0 +1,26 @@ +import { useMemo } from 'react' +import { useManyDocs } from '@comapeo/core-react' +import type { Observation } from '@comapeo/schema' +import { useIntl } from 'react-intl' + +import { matchPreset } from '../lib/matchPreset' + +export function useObservationWithPreset( + observation: Observation, + projectId: string, +) { + const { locale } = useIntl() + const { data: presets = [] } = useManyDocs({ + projectId: projectId || '', + docType: 'preset', + includeDeleted: false, + lang: locale, + }) + + const matchedPreset = useMemo(() => { + if (!observation?.tags) return undefined + return matchPreset(observation.tags, presets) + }, [observation?.tags, presets]) + + return matchedPreset +} diff --git a/src/renderer/src/images/AddPerson.svg b/src/renderer/src/images/AddPerson.svg new file mode 100644 index 0000000..62030f0 --- /dev/null +++ b/src/renderer/src/images/AddPerson.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/renderer/src/images/Lightning.svg b/src/renderer/src/images/Lightning.svg new file mode 100644 index 0000000..312cd85 --- /dev/null +++ b/src/renderer/src/images/Lightning.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/renderer/src/images/Track.svg b/src/renderer/src/images/Track.svg new file mode 100644 index 0000000..abb0b8c --- /dev/null +++ b/src/renderer/src/images/Track.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/renderer/src/images/add_person.png b/src/renderer/src/images/add_person.png deleted file mode 100644 index 31add87..0000000 Binary files a/src/renderer/src/images/add_person.png and /dev/null differ diff --git a/src/renderer/src/images/empty_state.png b/src/renderer/src/images/empty_state.png new file mode 100644 index 0000000..a684299 Binary files /dev/null and b/src/renderer/src/images/empty_state.png differ diff --git a/src/renderer/src/images/pencil.png b/src/renderer/src/images/pencil.png new file mode 100644 index 0000000..5f34f0b Binary files /dev/null and b/src/renderer/src/images/pencil.png differ diff --git a/src/renderer/src/lib/matchPreset.ts b/src/renderer/src/lib/matchPreset.ts new file mode 100644 index 0000000..632c0a8 --- /dev/null +++ b/src/renderer/src/lib/matchPreset.ts @@ -0,0 +1,37 @@ +import type { Observation, Preset } from '@comapeo/schema' + +export function matchPreset( + availableTags: Observation['tags'], + presets: Array, +): Preset | undefined { + let bestMatch: Preset | undefined + let bestMatchScore = 0 + + presets.forEach((preset) => { + let score = 0 + const presetTagsCount = Object.keys(preset.tags).length + + for (const key in preset.tags) { + if (Object.prototype.hasOwnProperty.call(preset.tags, key)) { + const presetTag = preset.tags[key] + const availableTag = availableTags[key] + if (presetTag === availableTag) { + score++ + } else if ( + Array.isArray(presetTag) && + presetTag.includes(availableTag as boolean | number | string | null) + ) { + score++ + } + } + } + + score = (score / presetTagsCount) * 100 + if (score > bestMatchScore) { + bestMatchScore = score + bestMatch = preset + } + }) + + return bestMatch +} diff --git a/src/renderer/src/lib/utils.ts b/src/renderer/src/lib/utils.ts new file mode 100644 index 0000000..4f4396b --- /dev/null +++ b/src/renderer/src/lib/utils.ts @@ -0,0 +1,8 @@ +export function hexToRgba(hex: string, alpha: number): string { + const bigint = parseInt(hex.slice(1), 16) + const r = (bigint >> 16) & 255 + const g = (bigint >> 8) & 255 + const b = bigint & 255 + + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} diff --git a/src/renderer/src/routeTree.gen.ts b/src/renderer/src/routeTree.gen.ts index 1f8e799..fd4eff8 100644 --- a/src/renderer/src/routeTree.gen.ts +++ b/src/renderer/src/routeTree.gen.ts @@ -24,7 +24,7 @@ import { Route as OnboardingCreateProjectScreenImport } from './routes/Onboardin import { Route as OnboardingCreateJoinProjectScreenImport } from './routes/Onboarding/CreateJoinProjectScreen' import { Route as MapTabsMapImport } from './routes/(MapTabs)/_Map' import { Route as MapTabsMapTab2Import } from './routes/(MapTabs)/_Map.tab2' -import { Route as MapTabsMapTab1Import } from './routes/(MapTabs)/_Map.tab1' +import { Route as MapTabsMapMainImport } from './routes/(MapTabs)/_Map.main' // Create Virtual Routes @@ -107,9 +107,9 @@ const MapTabsMapTab2Route = MapTabsMapTab2Import.update({ getParentRoute: () => MapTabsMapRoute, } as any) -const MapTabsMapTab1Route = MapTabsMapTab1Import.update({ - id: '/tab1', - path: '/tab1', +const MapTabsMapMainRoute = MapTabsMapMainImport.update({ + id: '/main', + path: '/main', getParentRoute: () => MapTabsMapRoute, } as any) @@ -194,11 +194,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof OnboardingIndexImport parentRoute: typeof rootRoute } - '/(MapTabs)/_Map/tab1': { - id: '/(MapTabs)/_Map/tab1' - path: '/tab1' - fullPath: '/tab1' - preLoaderRoute: typeof MapTabsMapTab1Import + '/(MapTabs)/_Map/main': { + id: '/(MapTabs)/_Map/main' + path: '/main' + fullPath: '/main' + preLoaderRoute: typeof MapTabsMapMainImport parentRoute: typeof MapTabsMapImport } '/(MapTabs)/_Map/tab2': { @@ -214,12 +214,12 @@ declare module '@tanstack/react-router' { // Create and export the route tree interface MapTabsMapRouteChildren { - MapTabsMapTab1Route: typeof MapTabsMapTab1Route + MapTabsMapMainRoute: typeof MapTabsMapMainRoute MapTabsMapTab2Route: typeof MapTabsMapTab2Route } const MapTabsMapRouteChildren: MapTabsMapRouteChildren = { - MapTabsMapTab1Route: MapTabsMapTab1Route, + MapTabsMapMainRoute: MapTabsMapMainRoute, MapTabsMapTab2Route: MapTabsMapTab2Route, } @@ -248,7 +248,7 @@ export interface FileRoutesByFullPath { '/Onboarding/JoinProjectScreen': typeof OnboardingJoinProjectScreenRoute '/Onboarding/PrivacyPolicyScreen': typeof OnboardingPrivacyPolicyScreenRoute '/Onboarding': typeof OnboardingIndexRoute - '/tab1': typeof MapTabsMapTab1Route + '/main': typeof MapTabsMapMainRoute '/tab2': typeof MapTabsMapTab2Route } @@ -262,7 +262,7 @@ export interface FileRoutesByTo { '/Onboarding/JoinProjectScreen': typeof OnboardingJoinProjectScreenRoute '/Onboarding/PrivacyPolicyScreen': typeof OnboardingPrivacyPolicyScreenRoute '/Onboarding': typeof OnboardingIndexRoute - '/tab1': typeof MapTabsMapTab1Route + '/main': typeof MapTabsMapMainRoute '/tab2': typeof MapTabsMapTab2Route } @@ -279,7 +279,7 @@ export interface FileRoutesById { '/Onboarding/JoinProjectScreen': typeof OnboardingJoinProjectScreenRoute '/Onboarding/PrivacyPolicyScreen': typeof OnboardingPrivacyPolicyScreenRoute '/Onboarding/': typeof OnboardingIndexRoute - '/(MapTabs)/_Map/tab1': typeof MapTabsMapTab1Route + '/(MapTabs)/_Map/main': typeof MapTabsMapMainRoute '/(MapTabs)/_Map/tab2': typeof MapTabsMapTab2Route } @@ -295,7 +295,7 @@ export interface FileRouteTypes { | '/Onboarding/JoinProjectScreen' | '/Onboarding/PrivacyPolicyScreen' | '/Onboarding' - | '/tab1' + | '/main' | '/tab2' fileRoutesByTo: FileRoutesByTo to: @@ -308,7 +308,7 @@ export interface FileRouteTypes { | '/Onboarding/JoinProjectScreen' | '/Onboarding/PrivacyPolicyScreen' | '/Onboarding' - | '/tab1' + | '/main' | '/tab2' id: | '__root__' @@ -323,7 +323,7 @@ export interface FileRouteTypes { | '/Onboarding/JoinProjectScreen' | '/Onboarding/PrivacyPolicyScreen' | '/Onboarding/' - | '/(MapTabs)/_Map/tab1' + | '/(MapTabs)/_Map/main' | '/(MapTabs)/_Map/tab2' fileRoutesById: FileRoutesById } @@ -393,7 +393,7 @@ export const routeTree = rootRoute "filePath": "(MapTabs)/_Map.tsx", "parent": "/(MapTabs)", "children": [ - "/(MapTabs)/_Map/tab1", + "/(MapTabs)/_Map/main", "/(MapTabs)/_Map/tab2" ] }, @@ -418,8 +418,8 @@ export const routeTree = rootRoute "/Onboarding/": { "filePath": "Onboarding/index.tsx" }, - "/(MapTabs)/_Map/tab1": { - "filePath": "(MapTabs)/_Map.tab1.tsx", + "/(MapTabs)/_Map/main": { + "filePath": "(MapTabs)/_Map.main.tsx", "parent": "/(MapTabs)/_Map" }, "/(MapTabs)/_Map/tab2": { diff --git a/src/renderer/src/routes/(MapTabs)/_Map.main.tsx b/src/renderer/src/routes/(MapTabs)/_Map.main.tsx new file mode 100644 index 0000000..b23e5a2 --- /dev/null +++ b/src/renderer/src/routes/(MapTabs)/_Map.main.tsx @@ -0,0 +1,138 @@ +import * as React from 'react' +import { useManyDocs, useProjectSettings } from '@comapeo/core-react' +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { defineMessages, useIntl } from 'react-intl' + +import { EmptyState } from '../../components/Observations/EmptyState' +import { ObservationListView } from '../../components/Observations/ObservationListView' +import { ProjectHeader } from '../../components/Observations/ProjectHeader' +import { useActiveProjectIdStoreState } from '../../contexts/ActiveProjectIdProvider' + +const m = defineMessages({ + loading: { + id: 'mapMain.loading', + defaultMessage: 'Loading...', + }, + errorLoading: { + id: 'mapMain.errorLoading', + defaultMessage: 'Oops! Error loading data.', + }, + unnamedProject: { + id: 'mapMain.unnamedProject', + defaultMessage: 'Unnamed Project', + }, +}) + +export const Route = createFileRoute('/(MapTabs)/_Map/main')({ + component: MainScreen, +}) + +export function MainScreen() { + const navigate = useNavigate() + const { formatMessage, locale } = useIntl() + const activeProjectId = useActiveProjectIdStoreState((s) => s.activeProjectId) + const { data: projectSettings, error: settingsError } = useProjectSettings({ + projectId: activeProjectId || '', + }) + + const { + data: observations, + error: obsError, + isRefetching: isRefetchingObs, + } = useManyDocs({ + projectId: activeProjectId || '', + docType: 'observation', + includeDeleted: false, + lang: locale, + }) + + const { + data: tracks, + error: trackError, + isRefetching: isRefetchingTracks, + } = useManyDocs({ + projectId: activeProjectId || '', + docType: 'track', + includeDeleted: false, + lang: locale, + }) + + const combinedData = React.useMemo(() => { + const mappableObservations = observations ?? [] + const mappableTracks = tracks ?? [] + const allDocs = [...mappableObservations, ...mappableTracks].sort((a, b) => + a.createdAt < b.createdAt ? 1 : -1, + ) + return allDocs + }, [observations, tracks]) + + const handleViewExchange = React.useCallback(() => { + console.log('Clicking on view exchange (TODO in future)') + // navigate({ to: '/exchange' }) + }, [navigate]) + + const handleViewTeam = React.useCallback(() => { + console.log('Clicking on team (TODO in future)') + // navigate({ to: '/team' }) + }, [navigate]) + + const handleSelectObservation = (obsId: string) => { + console.log('Clicking on view observation (TODO in future)', obsId) + // navigate({ + // to: '/view-observation', + // params: { observationId: obsId }, + // }) + } + + const handleSelectTrack = React.useCallback( + (trackId: string) => { + console.log('Clicking on view track (TODO in future)', trackId) + // navigate({ + // to: '/view-track', + // params: { trackId }, + // }) + }, + [navigate], + ) + + const handleEditProjectName = () => { + console.log('Edit project name clicked (TODO in future).') + } + + if (isRefetchingTracks || isRefetchingObs) { + return
    {formatMessage(m.loading)}
    + } + + if (obsError || trackError || settingsError) { + return
    {formatMessage(m.errorLoading)}
    + } + + const projectName = projectSettings?.name || formatMessage(m.unnamedProject) + + return ( +
    + + {!combinedData.length ? ( + + ) : ( + + )} +
    + ) +} diff --git a/src/renderer/src/routes/(MapTabs)/_Map.tab1.tsx b/src/renderer/src/routes/(MapTabs)/_Map.tab1.tsx deleted file mode 100644 index cd83f30..0000000 --- a/src/renderer/src/routes/(MapTabs)/_Map.tab1.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import * as React from 'react' -import { createFileRoute } from '@tanstack/react-router' - -import { Text } from '../../components/Text' - -export const Route = createFileRoute('/(MapTabs)/_Map/tab1')({ - component: Observations, -}) - -export function Observations() { - return ( -
    - Tab 1 -
    - ) -} diff --git a/src/renderer/src/routes/(MapTabs)/_Map.tab2.tsx b/src/renderer/src/routes/(MapTabs)/_Map.tab2.tsx index 5171d08..90b5110 100644 --- a/src/renderer/src/routes/(MapTabs)/_Map.tab2.tsx +++ b/src/renderer/src/routes/(MapTabs)/_Map.tab2.tsx @@ -1,16 +1,46 @@ -import * as React from 'react' -import { createFileRoute } from '@tanstack/react-router' +import { createFileRoute, useNavigate } from '@tanstack/react-router' +import { Button } from '../../components/Button' import { Text } from '../../components/Text' +import { useActiveProjectIdStoreState } from '../../contexts/ActiveProjectIdProvider' +import { useCreateTestObservations } from '../../hooks/mutations/useCreateTestObservations' export const Route = createFileRoute('/(MapTabs)/_Map/tab2')({ component: Settings, }) export function Settings() { + const { mutate: createTestData, isPending } = useCreateTestObservations() + + const projectId = useActiveProjectIdStoreState((s) => s.activeProjectId) + const navigate = useNavigate() + + function handleCreateTestData() { + if (!projectId) { + console.error('No active project selected. Cannot create test data.') + return + } + createTestData( + { projectId: projectId, count: 20 }, + { + onSuccess: () => { + navigate({ to: '/main' }) + }, + onError: (err) => { + console.error('Error creating test data', err) + }, + }, + ) + } + return ( -
    - Tab 2 +
    + + Settings + +
    ) } diff --git a/src/renderer/src/routes/(MapTabs)/_Map.test.tsx b/src/renderer/src/routes/(MapTabs)/_Map.test.tsx index 8a2f953..3e3795b 100644 --- a/src/renderer/src/routes/(MapTabs)/_Map.test.tsx +++ b/src/renderer/src/routes/(MapTabs)/_Map.test.tsx @@ -17,7 +17,7 @@ const Wrapper = ({ children }: { children: ReactNode }) => ( {children} ) -// Creates a stubbed out router. We are just testing whether the navigation gets passed the correct route (aka "/tab1" or "/tab2") so we do not need the actual router and can just intecept the navgiation state. +// Creates a stubbed out router. We are just testing whether the navigation gets passed the correct route (aka "/main" or "/tab2") so we do not need the actual router and can just intecept the navgiation state. const mapRoute = createRoute({ getParentRoute: () => rootRoute, id: 'map', @@ -47,7 +47,7 @@ test('clicking tabs navigate to correct tab', () => { const observationTab = screen.getByTestId('tab-observation') observationTab.click() const observationTabRouteName = router.state.location.pathname - expect(observationTabRouteName).toStrictEqual('/tab1') + expect(observationTabRouteName).toStrictEqual('/main') const aboutTab = screen.getByText('About') aboutTab.click() diff --git a/src/renderer/src/routes/(MapTabs)/_Map.tsx b/src/renderer/src/routes/(MapTabs)/_Map.tsx index 893be30..87617b5 100644 --- a/src/renderer/src/routes/(MapTabs)/_Map.tsx +++ b/src/renderer/src/routes/(MapTabs)/_Map.tsx @@ -24,7 +24,7 @@ export function MapLayout() {
    - + {formatMessage(m.title)} diff --git a/src/renderer/src/routes/Onboarding/CreateProjectScreen.tsx b/src/renderer/src/routes/Onboarding/CreateProjectScreen.tsx index 294ee8a..c5b4abc 100644 --- a/src/renderer/src/routes/Onboarding/CreateProjectScreen.tsx +++ b/src/renderer/src/routes/Onboarding/CreateProjectScreen.tsx @@ -126,6 +126,7 @@ function CreateProjectScreenComponent() { const [fileName, setFileName] = useState() const createProjectMutation = useCreateProject() + const selectConfigFile = useSelectProjectConfigFile() const { setActiveProjectId } = useActiveProjectIdStoreActions() @@ -167,7 +168,7 @@ function CreateProjectScreenComponent() { { onSuccess: (projectId) => { setActiveProjectId(projectId) - navigate({ to: '/tab1' }) + navigate({ to: '/main' }) }, onError: (error) => { console.error('Error saving project:', error) diff --git a/src/renderer/src/routes/Onboarding/DataPrivacy.tsx b/src/renderer/src/routes/Onboarding/DataPrivacy.tsx index 1ca04bd..5695ac4 100644 --- a/src/renderer/src/routes/Onboarding/DataPrivacy.tsx +++ b/src/renderer/src/routes/Onboarding/DataPrivacy.tsx @@ -2,7 +2,7 @@ import { styled } from '@mui/material/styles' import { createFileRoute, useNavigate } from '@tanstack/react-router' import { defineMessages, useIntl } from 'react-intl' -import { BLACK, DARK_GREY, WHITE } from '../../colors' +import { ALMOST_BLACK, DARK_GREY, WHITE } from '../../colors' import { Button } from '../../components/Button' import { OnboardingScreenLayout } from '../../components/Onboarding/OnboardingScreenLayout' import { OnboardingTopMenu } from '../../components/Onboarding/OnboardingTopMenu' @@ -117,7 +117,7 @@ export function DataPrivacyComponent() { onClick={() => navigate({ to: '/Onboarding/PrivacyPolicyScreen' })} variant="outlined" style={{ - color: BLACK, + color: ALMOST_BLACK, backgroundColor: WHITE, width: '100%', }} diff --git a/src/renderer/src/routes/Onboarding/DeviceNamingScreen.tsx b/src/renderer/src/routes/Onboarding/DeviceNamingScreen.tsx index 31278b4..e927f32 100644 --- a/src/renderer/src/routes/Onboarding/DeviceNamingScreen.tsx +++ b/src/renderer/src/routes/Onboarding/DeviceNamingScreen.tsx @@ -5,7 +5,7 @@ import { styled } from '@mui/material/styles' import { createFileRoute, useNavigate } from '@tanstack/react-router' import { defineMessages, useIntl } from 'react-intl' -import { BLACK, RED, WHITE } from '../../colors' +import { ALMOST_BLACK, RED, WHITE } from '../../colors' import { Button } from '../../components/Button' import { OnboardingScreenLayout } from '../../components/Onboarding/OnboardingScreenLayout' import { OnboardingTopMenu } from '../../components/Onboarding/OnboardingTopMenu' @@ -79,7 +79,7 @@ const CharacterCount = styled(Text, { shouldForwardProp: (prop) => prop !== 'error', })<{ error: boolean }>(({ error }) => ({ marginTop: 12, - color: error ? RED : BLACK, + color: error ? RED : ALMOST_BLACK, width: '100%', maxWidth: 400, textAlign: 'right', diff --git a/src/renderer/src/routes/Onboarding/JoinProjectScreen.tsx b/src/renderer/src/routes/Onboarding/JoinProjectScreen.tsx index b75f77f..0689d72 100644 --- a/src/renderer/src/routes/Onboarding/JoinProjectScreen.tsx +++ b/src/renderer/src/routes/Onboarding/JoinProjectScreen.tsx @@ -62,7 +62,7 @@ function JoinProjectScreenComponent() { const handleJoin = () => { // TODO: Add logic to join project - navigate({ to: '/tab1' }) + navigate({ to: '/main' }) } const topMenu = ( diff --git a/src/renderer/src/routes/Welcome.tsx b/src/renderer/src/routes/Welcome.tsx index 3a9129e..a00e45e 100644 --- a/src/renderer/src/routes/Welcome.tsx +++ b/src/renderer/src/routes/Welcome.tsx @@ -11,7 +11,7 @@ function RouteComponent() { return (
    Welcome Page - Map + Map
    ) } diff --git a/src/renderer/src/routes/index.tsx b/src/renderer/src/routes/index.tsx index 7a2325d..d2628c2 100644 --- a/src/renderer/src/routes/index.tsx +++ b/src/renderer/src/routes/index.tsx @@ -30,7 +30,7 @@ function RouteComponent() { return } - navigate({ to: '/tab1' }) + navigate({ to: '/main' }) }) return null