From 5999020b4657e0950e572c358a807594eda06788 Mon Sep 17 00:00:00 2001 From: cimigree Date: Wed, 22 Jan 2025 09:47:53 -0500 Subject: [PATCH] feat: screen for empty observation and observation list (#84) * Adds button in settings tab to create test data. --- messages/renderer/en.json | 28 ++++ src/renderer/src/Theme.ts | 29 ++++ src/renderer/src/colors.ts | 3 + src/renderer/src/components/Button.tsx | 10 +- src/renderer/src/components/FormattedData.tsx | 23 +++ .../components/Observations/EmptyState.tsx | 88 +++++++++++ .../Observations/ObservationListItem.tsx | 84 +++++++++++ .../Observations/ObservationListView.tsx | 128 ++++++++++++++++ .../components/Observations/ProjectHeader.tsx | 34 +++++ .../components/Observations/TrackListItem.tsx | 68 +++++++++ .../Onboarding/OnboardingTopMenu.tsx | 8 +- .../Onboarding/PrivacyPolicy/index.tsx | 9 +- .../src/components/PresetCircleIcon.tsx | 103 +++++++++++++ src/renderer/src/components/Tabs.tsx | 2 +- src/renderer/src/components/Text.tsx | 22 ++- .../mutations/useCreateTestObservations.ts | 77 ++++++++++ .../src/hooks/useObservationWithPreset.ts | 26 ++++ src/renderer/src/images/AddPerson.svg | 3 + src/renderer/src/images/Lightning.svg | 3 + src/renderer/src/images/Track.svg | 12 ++ src/renderer/src/images/add_person.png | Bin 3223 -> 0 bytes src/renderer/src/images/empty_state.png | Bin 0 -> 11021 bytes src/renderer/src/images/pencil.png | Bin 0 -> 712 bytes src/renderer/src/lib/matchPreset.ts | 37 +++++ src/renderer/src/lib/utils.ts | 8 + src/renderer/src/routeTree.gen.ts | 40 ++--- .../src/routes/(MapTabs)/_Map.main.tsx | 138 ++++++++++++++++++ .../src/routes/(MapTabs)/_Map.tab1.tsx | 16 -- .../src/routes/(MapTabs)/_Map.tab2.tsx | 38 ++++- .../src/routes/(MapTabs)/_Map.test.tsx | 4 +- src/renderer/src/routes/(MapTabs)/_Map.tsx | 2 +- .../Onboarding/CreateJoinProjectScreen.tsx | 11 +- .../routes/Onboarding/CreateProjectScreen.tsx | 3 +- .../src/routes/Onboarding/DataPrivacy.tsx | 4 +- .../routes/Onboarding/DeviceNamingScreen.tsx | 4 +- .../routes/Onboarding/JoinProjectScreen.tsx | 2 +- src/renderer/src/routes/Welcome.tsx | 2 +- src/renderer/src/routes/index.tsx | 2 +- 38 files changed, 1002 insertions(+), 69 deletions(-) create mode 100644 src/renderer/src/components/FormattedData.tsx create mode 100644 src/renderer/src/components/Observations/EmptyState.tsx create mode 100644 src/renderer/src/components/Observations/ObservationListItem.tsx create mode 100644 src/renderer/src/components/Observations/ObservationListView.tsx create mode 100644 src/renderer/src/components/Observations/ProjectHeader.tsx create mode 100644 src/renderer/src/components/Observations/TrackListItem.tsx create mode 100644 src/renderer/src/components/PresetCircleIcon.tsx create mode 100644 src/renderer/src/hooks/mutations/useCreateTestObservations.ts create mode 100644 src/renderer/src/hooks/useObservationWithPreset.ts create mode 100644 src/renderer/src/images/AddPerson.svg create mode 100644 src/renderer/src/images/Lightning.svg create mode 100644 src/renderer/src/images/Track.svg delete mode 100644 src/renderer/src/images/add_person.png create mode 100644 src/renderer/src/images/empty_state.png create mode 100644 src/renderer/src/images/pencil.png create mode 100644 src/renderer/src/lib/matchPreset.ts create mode 100644 src/renderer/src/lib/utils.ts create mode 100644 src/renderer/src/routes/(MapTabs)/_Map.main.tsx delete mode 100644 src/renderer/src/routes/(MapTabs)/_Map.tab1.tsx 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 31add87ce8916c81c16993abcb12978844035a56..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3223 zcmb7{X*3iL_s54Y_U%X6*RhSv#MqS>BU=r_2+1J(zJ@Z4Fm{D(Aq>gju@g$NL?R3c z$y#>CUKs|D|NrIlyn0@I?)Th#?z!iD-+a%xiDpOxHf8~4006*dWQZ`o(54Gw7#S`q zs6kQYLYVvwZGr%Rt0w;kP1%j?ii;slkhuXIP%|vFe$mjm>E6)=0P3HyP#x(30M@HU z2wij-4f&;RAliUi2UbP+rj+cVYF#*_%<>TtCLTbmBBP^C^I)-Vmmz@mBa@DxEk+y} zrA{-%}H9OkY;s_aqS{Xjb=Tk;MCl&(N&5k?COrMfU5WPX$YYl z7}0*VxUZ1y{kv8UVV1#di+>6*Qc#z;xsWjPWWx~d&HEM(dY?O|6&~-meOHXzI)RO4 z#n;YH%yKPhfjF~Bq`vw|#PYmQbo)^CE47rSEEqFi@ZPsyI~nata3Xg#TB$(|f|k`G z;NZ{wsYOy#vXnkQrG)L%w(H?Q&sb(V35j_9w;U?bfV-QZ<`t5ZxOsw*nT2)cx7x{~ zd3-;LV4@S~Y6L6Z7Mm-iJd)p#Phl43t8ndSb5ICeanf<92{ft&rkB8hp#8BK=Ow|y zFIm=`1uX*#A* z2({s%pl4sz^5750QkbidT=?Y$`cGwBJtsc#xJ|xT4T;=%Gyjcxt!CWb&r@%p#7|Z_ z28#Z}M09NCUj98MyUaD};FsFvr~1}{O@DSo4M3k4DXHGr{i0+4jR4p_jc9@;NjQmNOXZ>jZ`+ePO_hT8&2oa;j^!|tv7#d*U!7<{c z$$7B~Wtn8-Oyc&?sR0}n6$CZJx_gGi<{YB@Xs&n7Fautv|urKt~m35MfRt932jnASvJ8mc&*n=59j zNtyPx!)K(0xGXt3p?Z`{6G(Z1VI{=iBX=${OuoW1@<4<2NM-8n-?)7+)(=cZH^&~c2xWE>8}=D&#w}VKJDR4RcI-Ars%xpbg?kQbmr7Co(QQ=uhO~PC=oMNwvR*<^oUzuTIA{dJVip41{f3wN~fNsCho?p6!fyjFSzFqHvp1mT&h|C?;GXLqYpMvADma((l`3tUD7bybt>Rmj7uN$X5l znmKD@Pu3h6oE7fyK8vyXJH30x8Rcya&iiETB{TQ&tV1r!qX{Or?ky#?CiG~C!HS?R9K}A13-5`KK1l`&as}{E8Xld+z$phjBR15!+Z=hj zs~n9KbB$th52<}2>r>>*KUgA(CcR&)d2&mj7+>E$A<`6-xQ`NEa(obPf6FfSEj1p( zm%+vV$azH2tYHZo5tih9T>lyhZZhZ#zp8$Wae8va@!H%Eq4IW_(zJrFgsc+BCGVoI zIcJ=UdvoKF&}_6mJ_G?OJlh%Po=mP;tl5ffBnKSc5kT5HA1eU&q>L;v3ijKd4cU<> z)vA;^@FAUHudXZeK7HFDO8tW}%kQV1HT|0aJ0xn`=y2u3-vj|Km%(bd=<}ZRTxi>D zdUr84n)>>mRj#e1{yPY-`~Fv5g7y6gF3%u*^ZKpZ3lDSu2sc}<=@}{yuljs-Pl@3cdO^2v zfGuTaiyvb?4?<=B%4iV|+PM)?iNBj(>O3y#tW>_`A&RtdxB3jBreslgKI4)XYntPHZD~a7ipU}M&i^OOY7qtPK|8U z;#G++;FCLucz;IibJH$_)DKco`8C>uleqA0pSvr=%~b!cA3?&JK_q_ehcX^&cU6m? zYuXTgW%9$a|JL^p;?{eyAn<$?J_G~u6N7wIg~#;Txm4^#{yberp7F+i=RTU26C?6L= zv5`HL>A7MGLwj2JWi_OpXlo@!>`f6GQ3EiV;o06%dAC5*CKTD&(M)G7hvn1M%%|jr zKQ&A?S|UxDI^KMKog;HBy>0bTm7+nKJA{eA<;X51C*2CR6z2u`<1g6?ShB+>X(M0r zrg+L>a4oV5EE*M=WX**r`p2xs%bX+&|I$)PIUUa&Vp*GPzhLdV8?>^R=O2DJtmViPB+kNLK_c?Psa!L#Dj18y^@R1-~l)N?^Jk;4k6d40+%Jv ZfK&25er7GfjTe6ez(^m7sDV2~{~NS%20s7* diff --git a/src/renderer/src/images/empty_state.png b/src/renderer/src/images/empty_state.png new file mode 100644 index 0000000000000000000000000000000000000000..a684299e72e67f74b3c9f1b7c161370d537cbeaa GIT binary patch literal 11021 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 literal 0 HcmV?d00001 diff --git a/src/renderer/src/images/pencil.png b/src/renderer/src/images/pencil.png new file mode 100644 index 0000000000000000000000000000000000000000..5f34f0b8e11e73b5da9c59a6131bd9c4b37e6dcd GIT binary patch literal 712 zcmV;(0yq7MP)@~0drDELIAGL9O(c600d`2O+f$vv5yP^Fanz-Dd@=rAsbL3MZ9u95+fjfp3mp| z-+hv0%fP|+>3G?87;${xZ>-nrXTJG#I2@XPyBAn$wc02Mf@RX<9Y&abzYlSrFO9hS zIF93w$Kz4l?KMV}!C>&hR{>vcQtsMmx7%z!pT9@lNHPHA`E>VlJR~*(xc~z4A(0VC z1t=gNf(T0<;UnBZCV&L_kcaaKBmzj34>?89{%f!V`H)q;UT zvV6!=%K(bxk7yS_r999ofO2`qS-4CSRLcYYdX zuEzt)1Bl9l>2$i{5$^fs8_P-5ctGj5KoR+CdcEF^e(&Y zJSY@^x;zjLV1hgl3Sf#n$Q{5Wd5|lBY4RX*02AdwrU0hOgVX>f%Y(!Krptra05-^j z$N;v;gMR^Rk_Q(7Y?BA405-~lV*p#_fn&2gaBY?cspkewmIsLeOqU0V0X)x}u@orF zgF>grsmlZ504B%-p#Y}HgS4x}n#1AnV#j{J@0ubH5(DgZyH_Rv6?q{28X-#ZKxzPL u@<3Vuit<280IKq!Z~)5kpiltn^56$y^VN}`VP;kU0000, +): 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