From 1e3a73de3467a6235f668e1ec7becb08e8e0a962 Mon Sep 17 00:00:00 2001 From: frozenhelium Date: Thu, 14 May 2026 00:39:46 +0545 Subject: [PATCH 1/2] feat(project): implement project mapping and tutorial for locate --- app/(auth)/(home)/projects.tsx | 3 +- app/(auth)/project/[id]/map/[taskGroupId].tsx | 11 + app/(auth)/project/[id]/tutorial.tsx | 5 + backend | 2 +- components/ButtonLayout.tsx | 6 + components/CompareMappingSession.tsx | 13 +- components/LocateFeaturesMappingSession.tsx | 433 +++++++++++++ components/LocateTile.tsx | 270 ++++++++ components/TileGridMappingSession.tsx | 13 +- .../tutorial/CompareTutorialSession.tsx | 13 +- components/tutorial/LocateInstructions.tsx | 82 +++ components/tutorial/LocateTutorialSession.tsx | 321 ++++++++++ components/tutorial/TapBadgeIcon.tsx | 2 +- .../tutorial/TileGridTutorialSession.tsx | 13 +- components/tutorial/TutorialIntroPage.tsx | 14 +- components/tutorial/TutorialPager.tsx | 17 +- components/tutorial/TutorialScenarioPage.tsx | 21 +- components/tutorial/types.ts | 2 + constants/common.ts | 2 + public/locales/en/instructionsScreen.json | 8 +- public/locales/en/mappingSession.json | 5 +- utils/firebase-generated-types.ts | 557 +++++++++++++++++ utils/task.ts | 3 +- utils/tutorial.ts | 58 +- utils/types.ts | 578 ++---------------- utils/urqlClient.ts | 2 +- 26 files changed, 1891 insertions(+), 563 deletions(-) create mode 100644 components/LocateFeaturesMappingSession.tsx create mode 100644 components/LocateTile.tsx create mode 100644 components/tutorial/LocateInstructions.tsx create mode 100644 components/tutorial/LocateTutorialSession.tsx create mode 100644 utils/firebase-generated-types.ts diff --git a/app/(auth)/(home)/projects.tsx b/app/(auth)/(home)/projects.tsx index dc54ef8..11619b3 100644 --- a/app/(auth)/(home)/projects.tsx +++ b/app/(auth)/(home)/projects.tsx @@ -38,6 +38,7 @@ import { PROJECT_TYPE_COMPARE, PROJECT_TYPE_COMPLETENESS, PROJECT_TYPE_FIND, + PROJECT_TYPE_LOCATE_FEATURES, PROJECT_TYPE_STREET, PROJECT_TYPE_VALIDATE, PROJECT_TYPE_VALIDATE_IMAGE, @@ -90,7 +91,6 @@ const createProjectStyles = (_: AppTheme, { featured }: { featured: boolean }) = }, projectImage: { height: 220, - aspectRatio: 1, }, overlay: { top: 0, @@ -147,6 +147,7 @@ const projectTypeTextMapping: Record = { [PROJECT_TYPE_VALIDATE]: 'validate', [PROJECT_TYPE_STREET]: 'street', [PROJECT_TYPE_VALIDATE_IMAGE]: 'validate image', + [PROJECT_TYPE_LOCATE_FEATURES]: 'locate features', }; interface ProjectItemProps { diff --git a/app/(auth)/project/[id]/map/[taskGroupId].tsx b/app/(auth)/project/[id]/map/[taskGroupId].tsx index 1b053a9..c4c34da 100644 --- a/app/(auth)/project/[id]/map/[taskGroupId].tsx +++ b/app/(auth)/project/[id]/map/[taskGroupId].tsx @@ -13,6 +13,7 @@ import { isNotDefined } from '@togglecorp/fujs'; import { set as setToDatabase } from 'firebase/database'; import CompareMappingSession from '@/components/CompareMappingSession'; +import LocateFeaturesMappingSession from '@/components/LocateFeaturesMappingSession'; import Page from '@/components/Page'; import SessionOutro, { type ResultSyncStatus } from '@/components/SessionOutro'; import StreetMappingSession from '@/components/StreetMappingSession'; @@ -27,6 +28,7 @@ import { PROJECT_TYPE_COMPARE, PROJECT_TYPE_COMPLETENESS, PROJECT_TYPE_FIND, + PROJECT_TYPE_LOCATE_FEATURES, PROJECT_TYPE_STREET, PROJECT_TYPE_VALIDATE, PROJECT_TYPE_VALIDATE_IMAGE, @@ -205,6 +207,15 @@ function MapTaskGroup() { projectDetails={projectDetails} /> )} + {projectDetails?.projectType === PROJECT_TYPE_LOCATE_FEATURES && ( + + )} )} diff --git a/app/(auth)/project/[id]/tutorial.tsx b/app/(auth)/project/[id]/tutorial.tsx index 2de2b94..959fd03 100644 --- a/app/(auth)/project/[id]/tutorial.tsx +++ b/app/(auth)/project/[id]/tutorial.tsx @@ -178,6 +178,11 @@ function Tutorial() { attemptCounts={attemptCounts} onScenarioSubmit={handleScenarioSubmit} onScenarioShowAnswers={handleScenarioShowAnswers} + projectCustomOptions={ + projectDetails && 'customOptions' in projectDetails + ? projectDetails.customOptions + : undefined + } /> )} diff --git a/backend b/backend index 778b414..c5bfe14 160000 --- a/backend +++ b/backend @@ -1 +1 @@ -Subproject commit 778b414cfc83d43809cce741f7489c8278cc6746 +Subproject commit c5bfe14dc6f6b3417f09c3f2d4a19bb0f4ea4a70 diff --git a/components/ButtonLayout.tsx b/components/ButtonLayout.tsx index 4830185..5060c31 100644 --- a/components/ButtonLayout.tsx +++ b/components/ButtonLayout.tsx @@ -97,6 +97,9 @@ const createStyles = ( disabled: { opacity: 0.4, }, + leftContent: { + marginRight: 'auto', + }, rightContent: { marginLeft: 'auto', }, @@ -112,6 +115,7 @@ export interface ButtonLayoutProps extends Omit void; + icon?: React.ReactNode; children?: React.ReactNode; action?: React.ReactNode accessibilityLabel?: string; @@ -124,6 +128,7 @@ function ButtonLayout(props: ButtonLayoutProps) { spacingOffset = -1, withoutPadding = false, disabled, + icon, iconName, title, onPress, @@ -167,6 +172,7 @@ function ButtonLayout(props: ButtonLayoutProps) { {...inlineLayoutProps} withCenteredContent={styleVariant !== 'block'} > + {icon && {icon}} {isDefined(iconName) && ( { - onResultsChange((prevResults) => ({ - ...prevResults, - [taskId]: getNextValue(prevResults[taskId]), - })); + onResultsChange((prevResults) => { + const prevValue = prevResults[taskId]; + return { + ...prevResults, + [taskId]: getNextValue(typeof prevValue === 'number' ? prevValue : undefined), + }; + }); }, [getNextValue, onResultsChange]); const optionsByValue = useMemo(() => ( @@ -203,7 +206,7 @@ function CompareMappingSession(props: Props) { keyExtractor={(task) => task.taskId} renderItem={({ item: task }) => { const result = results[task.taskId]; - const selectedOption = isDefined(result) + const selectedOption = typeof result === 'number' ? optionsByValue[result] : undefined; diff --git a/components/LocateFeaturesMappingSession.tsx b/components/LocateFeaturesMappingSession.tsx new file mode 100644 index 0000000..0c855b6 --- /dev/null +++ b/components/LocateFeaturesMappingSession.tsx @@ -0,0 +1,433 @@ +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { + FlatList, + type NativeScrollEvent, + type NativeSyntheticEvent, + StyleSheet, + useWindowDimensions, + View, +} from 'react-native'; +import { + compareNumber, + isDefined, + isNotDefined, + listToMap, +} from '@togglecorp/fujs'; +import { + CheckIcon, + SelectionIcon, + SkipForwardIcon, +} from 'phosphor-react-native'; + +import Button from '@/components/Button'; +import InlineListView from '@/components/InlineListView'; +import LocateTile from '@/components/LocateTile'; +import ProgressBar from '@/components/ProgressBar'; +import ScaleBar from '@/components/ScaleBar'; +import { SCREEN_WIDTH } from '@/constants/dimensions'; +import useFirebaseDatabase from '@/hooks/useFirebaseDatabase'; +import useTheme from '@/hooks/useTheme'; +import useThemedStyles from '@/hooks/useThemedStyles'; +import { firebaseRef } from '@/utils/firebase'; +import { buildTasks } from '@/utils/task'; +import { + FbMappingGroupTileMapServiceCreateOnlyInput, + LocateFeaturesProject, + ResultOption, + Results, +} from '@/utils/types'; + +const createStyles = () => StyleSheet.create({ + content: { + alignItems: 'center', + }, + taskContent: { + alignItems: 'center', + width: SCREEN_WIDTH, + paddingBottom: 40, + }, + controls: { + position: 'absolute', + top: 10, + right: 0, + paddingHorizontal: 10, + justifyContent: 'center', + zIndex: 10, + elevation: 10, + }, +}); + +const VIEWABILITY_CONFIG = { + viewAreaCoveragePercentThreshold: 50, +}; + +// Locate features stores an array of cell values per task. CellResults is the +// locate-specific projection of the shared Results union. +type CellResults = Record; + +interface Props { + taskGroupId: string; + projectDetails: LocateFeaturesProject; + onResultsChange: Dispatch>; + results: Results; + onSessionComplete: () => void; +} + +function LocateFeaturesMappingSession(props: Props) { + const { + taskGroupId, + projectDetails, + results, + onResultsChange, + onSessionComplete, + } = props; + + const [currentTaskIndex, setCurrentTaskIndex] = useState(0); + const [mode, setMode] = useState<'mapping' | 'selection'>('mapping'); + const [selectedCellsByTask, setSelectedCellsByTask] = useState>({}); + const completedRef = useRef(false); + + const theme = useTheme(); + const { t } = useTranslation('mappingSession'); + + const styles = useThemedStyles(createStyles); + + const { + width: pageWidth, + height: pageHeight, + } = useWindowDimensions(); + + const gridSize = useMemo(() => ( + parseInt(projectDetails.subGridSize.split('x')[0], 10) + ), [projectDetails.subGridSize]); + + const cellsPerTile = gridSize * gridSize; + + const taskGroupQuery = useMemo(() => ( + firebaseRef(`v2/groups/${projectDetails.projectId}/${taskGroupId}`) + ), [taskGroupId, projectDetails.projectId]); + + const { data: groupDetails } = useFirebaseDatabase< + FbMappingGroupTileMapServiceCreateOnlyInput + >({ + query: taskGroupQuery, + }); + + const tasks = useMemo(() => { + if (isNotDefined(groupDetails)) { + return []; + } + return buildTasks(projectDetails, groupDetails); + }, [groupDetails, projectDetails]); + + const options = useMemo(() => { + if (isDefined(projectDetails.customOptions) && projectDetails.customOptions.length > 0) { + return projectDetails.customOptions.map((option) => ({ + value: option.value, + label: option.title, + color: option.iconColor, + })).sort((a, b) => compareNumber(a.value, b.value)); + } + + return [ + { value: 0, label: 'No', color: 'transparent' }, + { value: 1, label: 'Yes', color: 'green' }, + ]; + }, [projectDetails.customOptions]); + + const optionsByValue = useMemo(() => ( + listToMap(options, ({ value }) => value) + ), [options]); + + const defaultCellValue = options[0].value; + + const getNextValue = useCallback((value: number | undefined) => { + if (isNotDefined(value)) { + return defaultCellValue; + } + + const optionIndex = options.findIndex( + ({ value: optionValue }) => value === optionValue, + ); + + const nextIndex = optionIndex + 1; + if (optionIndex === -1 || nextIndex >= options.length) { + return options[0].value; + } + + return options[nextIndex].value; + }, [options, defaultCellValue]); + + useEffect(() => { + if (tasks.length === 0) { + return; + } + + onResultsChange((prev) => { + if (Object.keys(prev).length > 0) { + return prev; + } + + const seeded: CellResults = listToMap( + tasks, + ({ taskId }) => taskId, + () => new Array(cellsPerTile).fill(defaultCellValue), + ); + + return seeded; + }); + }, [tasks, cellsPerTile, defaultCellValue, onResultsChange]); + + const handleCellPress = useCallback((taskId: string, cellIndex: number) => { + onResultsChange((prev) => { + const existing = prev[taskId]; + const prevCells = Array.isArray(existing) + ? existing + : new Array(cellsPerTile).fill(defaultCellValue); + const nextCells = [...prevCells]; + nextCells[cellIndex] = getNextValue(prevCells[cellIndex]); + const next: CellResults = { + ...(prev as CellResults), + [taskId]: nextCells, + }; + return next; + }); + }, [getNextValue, onResultsChange, cellsPerTile, defaultCellValue]); + + const handleCellSelect = useCallback(( + taskId: string, + cellIndex: number, + action: 'select' | 'deselect', + ) => { + setSelectedCellsByTask((prev) => { + const existing = prev[taskId] ?? []; + const has = existing.includes(cellIndex); + if (action === 'select' && has) { + return prev; + } + if (action === 'deselect' && !has) { + return prev; + } + const nextCells = action === 'select' + ? [...existing, cellIndex] + : existing.filter((idx) => idx !== cellIndex); + return { + ...prev, + [taskId]: nextCells, + }; + }); + }, []); + + const currentTaskId = tasks[currentTaskIndex]?.taskId; + + const handleEnterSelectionMode = useCallback(() => { + setMode('selection'); + }, []); + + const handleExitSelectionMode = useCallback(() => { + setMode('mapping'); + if (isDefined(currentTaskId)) { + setSelectedCellsByTask((prev) => { + if (isNotDefined(prev[currentTaskId])) { + return prev; + } + const next = { ...prev }; + delete next[currentTaskId]; + return next; + }); + } + }, [currentTaskId]); + + const handleCycleSelected = useCallback(() => { + if (isNotDefined(currentTaskId)) { + return; + } + const selected = selectedCellsByTask[currentTaskId] ?? []; + if (selected.length === 0) { + return; + } + onResultsChange((prev) => { + const existing = prev[currentTaskId]; + const prevCells = Array.isArray(existing) + ? existing + : new Array(cellsPerTile).fill(defaultCellValue); + const nextCells = [...prevCells]; + selected.forEach((idx) => { + nextCells[idx] = getNextValue(prevCells[idx]); + }); + const next: CellResults = { + ...(prev as CellResults), + [currentTaskId]: nextCells, + }; + return next; + }); + }, [ + currentTaskId, + selectedCellsByTask, + onResultsChange, + cellsPerTile, + defaultCellValue, + getNextValue, + ]); + + const handleScroll = useCallback((event: { nativeEvent: { contentOffset: { x: number } } }) => { + const offsetX = event.nativeEvent.contentOffset.x; + const pageIndex = Math.round(offsetX / pageWidth); + setCurrentTaskIndex(Math.min(pageIndex, tasks.length - 1)); + }, [pageWidth, tasks.length]); + + const handleMomentumScrollEnd = useCallback(( + event: NativeSyntheticEvent, + ) => { + if (completedRef.current) { + return; + } + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + const maxScrollable = contentSize.width - layoutMeasurement.width; + if (maxScrollable <= 0) { + return; + } + if (contentOffset.x >= maxScrollable - 1) { + completedRef.current = true; + onSessionComplete(); + } + }, [onSessionComplete]); + + const tileWidth = Math.min(pageWidth - 20, pageHeight / 2); + + const latitude = useMemo(() => { + if (!groupDetails) { + return undefined; + } + return ( + Math.atan( + Math.sinh(Math.PI * (1 - (2 * groupDetails.yMin) / 2 ** projectDetails.zoomLevel)), + ) * (180 / Math.PI) + ); + }, [projectDetails, groupDetails]); + + return ( + <> + + + {mode === 'mapping' && ( + + )} + {mode === 'selection' && ( + <> + + + + )} + + + task.taskId} + renderItem={({ item: task }) => { + if (!task.url) { + return null; + } + const existing = results[task.taskId]; + const cellValues = Array.isArray(existing) + ? existing + : new Array(cellsPerTile).fill(defaultCellValue); + const selectedCells = selectedCellsByTask[task.taskId] ?? []; + + return ( + + ( + handleCellPress(task.taskId, cellIndex) + )} + onCellSelect={(cellIndex, action) => ( + handleCellSelect(task.taskId, cellIndex, action) + )} + /> + + ); + }} + horizontal + pagingEnabled + scrollEnabled={mode !== 'selection'} + decelerationRate="fast" + showsHorizontalScrollIndicator={false} + disableIntervalMomentum + snapToOffsets={tasks.map((_, i) => i * pageWidth)} + viewabilityConfig={VIEWABILITY_CONFIG} + onScroll={handleScroll} + onMomentumScrollEnd={handleMomentumScrollEnd} + scrollEventThrottle={16} + windowSize={3} + initialNumToRender={2} + /> + {latitude && ( + + )} + + + ); +} + +export default LocateFeaturesMappingSession; diff --git a/components/LocateTile.tsx b/components/LocateTile.tsx new file mode 100644 index 0000000..f001a38 --- /dev/null +++ b/components/LocateTile.tsx @@ -0,0 +1,270 @@ +import { + useCallback, + useEffect, + useMemo, + useRef, +} from 'react'; +import { + type GestureResponderEvent, + ImageBackground, + PanResponder, + Pressable, + StyleSheet, + View, +} from 'react-native'; + +import { type AppTheme } from '@/constants/theme'; +import useThemedStyles from '@/hooks/useThemedStyles'; +import { ResultOption } from '@/utils/types'; + +const createTileStyles = ( + theme: AppTheme, + { width }: { width: number }, +) => StyleSheet.create({ + tile: { + position: 'relative', + width, + height: width, + userSelect: 'none', + }, + image: { + width, + height: width, + borderColor: theme.mapBoundary, + borderWidth: 1, + }, + grid: { + position: 'absolute', + top: 0, + left: 0, + width, + height: width, + flexDirection: 'row', + flexWrap: 'wrap', + }, + gestureOverlay: { + position: 'absolute', + top: 0, + left: 0, + width, + height: width, + }, +}); + +const createCellStyles = ( + _: AppTheme, + { size, color, isSelected }: { + size: number, + color: string | undefined, + isSelected: boolean, + }, +) => StyleSheet.create({ + cell: { + width: size, + height: size, + position: 'relative', + }, + tint: { + ...StyleSheet.absoluteFillObject, + backgroundColor: color, + borderColor: 'rgba(255, 255, 255, 1)', + borderWidth: 0.5, + opacity: 0.6, + }, + selectionIndicator: { + ...StyleSheet.absoluteFillObject, + borderColor: isSelected ? 'rgba(255, 255, 255, 0.5)' : 'transparent', + borderWidth: isSelected ? 5 : 0, + }, +}); + +interface GridCellProps { + cellIndex: number; + size: number; + color: string | undefined; + isSelected: boolean; + disabled: boolean; + onPress: (cellIndex: number) => void; +} + +function GridCell(props: GridCellProps) { + const { + cellIndex, + size, + color, + isSelected, + disabled, + onPress, + } = props; + + const styles = useThemedStyles(createCellStyles, { size, color, isSelected }); + + const handlePress = useCallback(() => { + onPress(cellIndex); + }, [cellIndex, onPress]); + + return ( + + + + + ); +} + +interface Props { + url: string; + width: number; + gridSize: number; + cellValues: number[]; + optionsByValue: Record; + onCellPress: (cellIndex: number) => void; + + selectedCells?: number[]; + selectionMode?: boolean; + onCellSelect?: (cellIndex: number, action: 'select' | 'deselect') => void; + isCellInteractive?: (cellIndex: number) => boolean; +} + +function LocateTile(props: Props) { + const { + url, + width, + gridSize, + cellValues, + optionsByValue, + onCellPress, + selectedCells, + selectionMode = false, + onCellSelect, + isCellInteractive, + } = props; + + const styles = useThemedStyles(createTileStyles, { width }); + const cellSize = width / gridSize; + const selectedSet = useMemo(() => new Set(selectedCells ?? []), [selectedCells]); + + const gestureActionRef = useRef<'select' | 'deselect'>('select'); + const lastTouchedCellRef = useRef(undefined); + + const latestRef = useRef({ + width, + cellSize, + gridSize, + selectedSet, + onCellSelect, + }); + useEffect(() => { + latestRef.current = { + width, + cellSize, + gridSize, + selectedSet, + onCellSelect, + }; + }); + + const shouldSetResponder = useCallback(() => true, []); + + const handleGrant = useCallback((e: GestureResponderEvent) => { + const latest = latestRef.current; + if (!latest.onCellSelect) { + return; + } + const { locationX, locationY } = e.nativeEvent; + if ( + locationX < 0 || locationY < 0 + || locationX >= latest.width || locationY >= latest.width + ) { + return; + } + const col = Math.floor(locationX / latest.cellSize); + const row = Math.floor(locationY / latest.cellSize); + if (col < 0 || col >= latest.gridSize || row < 0 || row >= latest.gridSize) { + return; + } + const idx = row * latest.gridSize + col; + gestureActionRef.current = latest.selectedSet.has(idx) ? 'deselect' : 'select'; + lastTouchedCellRef.current = idx; + latest.onCellSelect(idx, gestureActionRef.current); + }, []); + + const handleMove = useCallback((e: GestureResponderEvent) => { + const latest = latestRef.current; + if (!latest.onCellSelect) { + return; + } + const { locationX, locationY } = e.nativeEvent; + if ( + locationX < 0 || locationY < 0 + || locationX >= latest.width || locationY >= latest.width + ) { + return; + } + const col = Math.floor(locationX / latest.cellSize); + const row = Math.floor(locationY / latest.cellSize); + if (col < 0 || col >= latest.gridSize || row < 0 || row >= latest.gridSize) { + return; + } + const idx = row * latest.gridSize + col; + if (idx === lastTouchedCellRef.current) { + return; + } + lastTouchedCellRef.current = idx; + latest.onCellSelect(idx, gestureActionRef.current); + }, []); + + const handleReleaseOrTerminate = useCallback(() => { + lastTouchedCellRef.current = undefined; + }, []); + + // False positive: PanResponder.create stores the handlers and invokes them + // during gestures, not during render. The refs read inside each useCallback + // handler are only accessed at gesture time. + // eslint-disable-next-line react-hooks/refs + const panResponder = useMemo(() => PanResponder.create({ + onStartShouldSetPanResponder: shouldSetResponder, + onStartShouldSetPanResponderCapture: shouldSetResponder, + onMoveShouldSetPanResponder: shouldSetResponder, + onMoveShouldSetPanResponderCapture: shouldSetResponder, + onPanResponderGrant: handleGrant, + onPanResponderMove: handleMove, + onPanResponderRelease: handleReleaseOrTerminate, + onPanResponderTerminate: handleReleaseOrTerminate, + }), [shouldSetResponder, handleGrant, handleMove, handleReleaseOrTerminate]); + + return ( + + + + {cellValues.map((value, cellIndex) => ( + + ))} + + {selectionMode && onCellSelect && ( + + )} + + ); +} + +export default LocateTile; diff --git a/components/TileGridMappingSession.tsx b/components/TileGridMappingSession.tsx index 21bc92f..35d65b5 100644 --- a/components/TileGridMappingSession.tsx +++ b/components/TileGridMappingSession.tsx @@ -205,10 +205,13 @@ function TileGridMappingSession(props: Props) { }, [onSessionComplete]); const handleTilePress = useCallback((taskId: string) => { - onResultsChange((prevResults) => ({ - ...prevResults, - [taskId]: getNextValue(prevResults[taskId]), - })); + onResultsChange((prevResults) => { + const prevValue = prevResults[taskId]; + return { + ...prevResults, + [taskId]: getNextValue(typeof prevValue === 'number' ? prevValue : undefined), + }; + }); }, [getNextValue, onResultsChange]); const latitude = useMemo(() => { @@ -232,7 +235,7 @@ function TileGridMappingSession(props: Props) { {groupedTasksFromRenderer.taskList.map((task) => { const result = results[task.taskId]; - const selectedOption = isDefined(result) + const selectedOption = typeof result === 'number' ? optionsByValue[result] : undefined; diff --git a/components/tutorial/CompareTutorialSession.tsx b/components/tutorial/CompareTutorialSession.tsx index ddf2e7a..27125a4 100644 --- a/components/tutorial/CompareTutorialSession.tsx +++ b/components/tutorial/CompareTutorialSession.tsx @@ -94,10 +94,13 @@ function CompareTutorialSession(props: TutorialSessionProps) { if (disabled) { return; } - onResultsChange((prev) => ({ - ...prev, - [taskId]: getNextValue(prev[taskId]), - })); + onResultsChange((prev) => { + const prevValue = prev[taskId]; + return { + ...prev, + [taskId]: getNextValue(typeof prevValue === 'number' ? prevValue : undefined), + }; + }); }, [disabled, onResultsChange]); return ( @@ -107,7 +110,7 @@ function CompareTutorialSession(props: TutorialSessionProps) { return null; } const result = results[task.taskId]; - const selectedOption = isDefined(result) + const selectedOption = typeof result === 'number' ? optionsByValue[result] : undefined; diff --git a/components/tutorial/LocateInstructions.tsx b/components/tutorial/LocateInstructions.tsx new file mode 100644 index 0000000..fe049a9 --- /dev/null +++ b/components/tutorial/LocateInstructions.tsx @@ -0,0 +1,82 @@ +import { useTranslation } from 'react-i18next'; +import { StyleSheet } from 'react-native'; +import { + CheckIcon, + SelectionIcon, +} from 'phosphor-react-native'; + +import BlockListView from '@/components/BlockListView'; +import Icon from '@/components/Icon'; +import Text from '@/components/Text'; +import { FbObjCustomOption } from '@/utils/types'; + +import InstructionRow from './InstructionRow'; +import TapBadgeIcon from './TapBadgeIcon'; + +const styles = StyleSheet.create({ + sectionHeading: { + fontWeight: 'bold', + }, +}); + +interface Props { + customOptions?: FbObjCustomOption[]; +} + +function LocateInstructions(props: Props) { + const { customOptions } = props; + const { t } = useTranslation('instructionsScreen'); + + const tapOptions = (customOptions ?? []) + .filter((option) => option.value > 0) + .sort((a, b) => a.value - b.value); + + return ( + + + {t('locateIntro')} + + + } + description={t('locateSwipe')} + /> + + {tapOptions.map((option) => ( + + )} + title={option.title} + description={option.description} + /> + ))} + + } + description={t('locateTapReset')} + /> + + + {t('locateMultiSelectSection')} + + + } + description={t('locateMultiSelectDrag')} + /> + + } + description={t('locateMultiSelectConfirm')} + /> + + ); +} + +export default LocateInstructions; diff --git a/components/tutorial/LocateTutorialSession.tsx b/components/tutorial/LocateTutorialSession.tsx new file mode 100644 index 0000000..39da8dc --- /dev/null +++ b/components/tutorial/LocateTutorialSession.tsx @@ -0,0 +1,321 @@ +import { + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { + StyleSheet, + useWindowDimensions, + View, +} from 'react-native'; +import { + isNotDefined, + listToGroupList, + listToMap, + mapToList, +} from '@togglecorp/fujs'; +import { + CheckIcon, + SelectionIcon, + SkipForwardIcon, +} from 'phosphor-react-native'; + +import Button from '@/components/Button'; +import InlineListView from '@/components/InlineListView'; +import LocateTile from '@/components/LocateTile'; +import { TutorialSessionProps } from '@/components/tutorial/types'; +import useTheme from '@/hooks/useTheme'; +import { TileTutorialTask } from '@/utils/tutorial'; +import { + ResultOption, + Results, +} from '@/utils/types'; + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + gap: 12, + }, + controls: { + flexGrow: 0, + alignSelf: 'flex-end', + }, +}); + +const DEFAULT_OPTIONS: ResultOption[] = [ + { value: 0, label: 'No', color: 'transparent' }, + { value: 1, label: 'Yes', color: 'green' }, +]; + +type LocateTutorialCellTask = TileTutorialTask & { + taskPartitionIndex: number; + taskId_real: string; + url: string; +}; + +function LocateTutorialSession(props: TutorialSessionProps) { + const { + tutorial, + tasks, + results, + onResultsChange, + disabled, + projectCustomOptions, + } = props; + + const { width: pageWidth, height: pageHeight } = useWindowDimensions(); + const theme = useTheme(); + const { t } = useTranslation('mappingSession'); + + const [mode, setMode] = useState<'mapping' | 'selection'>('mapping'); + const [selectedCellsByTile, setSelectedCellsByTile] = useState>({}); + + const options = useMemo(() => { + if (projectCustomOptions && projectCustomOptions.length > 0) { + return [...projectCustomOptions] + .sort((a, b) => a.value - b.value) + .map((option) => ({ + value: option.value, + label: option.title, + color: option.iconColor, + })); + } + return DEFAULT_OPTIONS; + }, [projectCustomOptions]); + + const optionsByValue = useMemo(() => ( + listToMap(options, ({ value }) => value) + ), [options]); + + const defaultCellValue = options[0].value; + + const getNextValue = useCallback((value: number | undefined) => { + if (isNotDefined(value)) { + return defaultCellValue; + } + const optionIndex = options.findIndex(({ value: v }) => value === v); + const nextIndex = optionIndex + 1; + if (optionIndex === -1 || nextIndex >= options.length) { + return options[0].value; + } + return options[nextIndex].value; + }, [options, defaultCellValue]); + + const gridSize = useMemo(() => { + if ('subGridSize' in tutorial && typeof tutorial.subGridSize === 'string') { + return parseInt(tutorial.subGridSize.split('x')[0], 10); + } + return 2; + }, [tutorial]); + + const cellsPerTile = gridSize * gridSize; + + // Group per-cell tasks by tile-level id (taskId_real). Multiple per-cell + // tasks share one tile; each carries the cell's referenceAnswer at its + // taskPartitionIndex. + const tileGroups = useMemo(() => { + const cellTasks = tasks.filter((task): task is LocateTutorialCellTask => ( + 'taskId_real' in task && typeof task.taskId_real === 'string' + && 'taskPartitionIndex' in task && typeof task.taskPartitionIndex === 'number' + && 'url' in task && typeof task.url === 'string' + )); + const grouped = listToGroupList(cellTasks, (task) => task.taskId_real); + return mapToList(grouped, (groupTasks, tileKey) => ({ + tileKey, + url: groupTasks[0]?.url, + tasks: groupTasks, + })); + }, [tasks]); + + // Seed: one array per tile, length = cellsPerTile, filled with default. + useEffect(() => { + if (tileGroups.length === 0) { + return; + } + onResultsChange((prev) => { + const missing = tileGroups.filter((g) => !(g.tileKey in prev)); + if (missing.length === 0) { + return prev; + } + const next: Results = { ...prev }; + missing.forEach((g) => { + next[g.tileKey] = new Array(cellsPerTile).fill(defaultCellValue); + }); + return next; + }); + }, [tileGroups, cellsPerTile, defaultCellValue, onResultsChange]); + + const handleCellPress = useCallback((tileKey: string, cellIndex: number) => { + if (disabled) { + return; + } + onResultsChange((prev) => { + const existing = prev[tileKey]; + const prevCells = Array.isArray(existing) + ? existing + : new Array(cellsPerTile).fill(defaultCellValue); + const nextCells = [...prevCells]; + nextCells[cellIndex] = getNextValue(prevCells[cellIndex]); + return { + ...prev, + [tileKey]: nextCells, + }; + }); + }, [disabled, onResultsChange, getNextValue, cellsPerTile, defaultCellValue]); + + const handleCellSelect = useCallback(( + tileKey: string, + cellIndex: number, + action: 'select' | 'deselect', + ) => { + setSelectedCellsByTile((prev) => { + const existing = prev[tileKey] ?? []; + const has = existing.includes(cellIndex); + if (action === 'select' && has) { + return prev; + } + if (action === 'deselect' && !has) { + return prev; + } + const nextCells = action === 'select' + ? [...existing, cellIndex] + : existing.filter((idx) => idx !== cellIndex); + return { + ...prev, + [tileKey]: nextCells, + }; + }); + }, []); + + // When the scenario is locked (correct / answers-shown), force-derive the + // effective mode to 'mapping' so the overlay and controls disappear — + // without writing state from an effect. + const effectiveSelectionMode = mode === 'selection' && !disabled; + + const handleEnterSelectionMode = useCallback(() => { + setMode('selection'); + }, []); + + const handleExitSelectionMode = useCallback(() => { + setMode('mapping'); + setSelectedCellsByTile({}); + }, []); + + const handleCycleSelected = useCallback(() => { + const tilesWithSelection = Object.entries(selectedCellsByTile) + .filter(([, cells]) => cells.length > 0); + if (tilesWithSelection.length === 0) { + return; + } + onResultsChange((prev) => { + const next: Results = { ...prev }; + tilesWithSelection.forEach(([tileKey, cells]) => { + const existing = prev[tileKey]; + const prevCells = Array.isArray(existing) + ? existing + : new Array(cellsPerTile).fill(defaultCellValue); + const nextCells = [...prevCells]; + cells.forEach((idx) => { + nextCells[idx] = getNextValue(prevCells[idx]); + }); + next[tileKey] = nextCells; + }); + return next; + }); + }, [selectedCellsByTile, onResultsChange, cellsPerTile, defaultCellValue, getNextValue]); + + const tileWidth = useMemo(() => { + const horizontalBudget = pageWidth - 24; + const verticalBudget = pageHeight * 0.6; + return Math.max(160, Math.min(horizontalBudget, verticalBudget)); + }, [pageWidth, pageHeight]); + + return ( + + {!disabled && ( + + {mode === 'mapping' && ( + + )} + {mode === 'selection' && ( + <> + + + + )} + + )} + {tileGroups.map((group) => { + if (isNotDefined(group.url)) { + return null; + } + const existing = results[group.tileKey]; + const baseCells = Array.isArray(existing) ? existing : []; + const cellValues: number[] = []; + for (let i = 0; i < cellsPerTile; i += 1) { + cellValues.push(baseCells[i] ?? defaultCellValue); + } + const validCells = new Set(); + group.tasks.forEach((task) => { + const idx = task.taskPartitionIndex; + if (idx >= 0 && idx < cellsPerTile) { + validCells.add(idx); + } + }); + const selectedCells = selectedCellsByTile[group.tileKey] ?? []; + return ( + ( + !disabled && validCells.has(cellIndex) + )} + onCellPress={(cellIndex) => ( + handleCellPress(group.tileKey, cellIndex) + )} + onCellSelect={(cellIndex, action) => ( + handleCellSelect(group.tileKey, cellIndex, action) + )} + /> + ); + })} + + ); +} + +export default LocateTutorialSession; diff --git a/components/tutorial/TapBadgeIcon.tsx b/components/tutorial/TapBadgeIcon.tsx index 435a695..c28f5d9 100644 --- a/components/tutorial/TapBadgeIcon.tsx +++ b/components/tutorial/TapBadgeIcon.tsx @@ -35,7 +35,7 @@ const styles = StyleSheet.create({ interface Props { iconName?: IconName; - badgeNumber?: 1 | 2 | 3; + badgeNumber?: number; badgeColor?: string; } diff --git a/components/tutorial/TileGridTutorialSession.tsx b/components/tutorial/TileGridTutorialSession.tsx index 286b228..fa7d94c 100644 --- a/components/tutorial/TileGridTutorialSession.tsx +++ b/components/tutorial/TileGridTutorialSession.tsx @@ -117,10 +117,13 @@ function TileGridTutorialSession(props: TutorialSessionProps) { if (disabled) { return; } - onResultsChange((prev) => ({ - ...prev, - [taskId]: getNextValue(prev[taskId]), - })); + onResultsChange((prev) => { + const prevValue = prev[taskId]; + return { + ...prev, + [taskId]: getNextValue(typeof prevValue === 'number' ? prevValue : undefined), + }; + }); }, [disabled, onResultsChange]); return ( @@ -130,7 +133,7 @@ function TileGridTutorialSession(props: TutorialSessionProps) { {column.rows.map((task) => { const result = results[task.taskId]; - const selectedOption = isDefined(result) + const selectedOption = typeof result === 'number' ? optionsByValue[result] : undefined; diff --git a/components/tutorial/TutorialIntroPage.tsx b/components/tutorial/TutorialIntroPage.tsx index ba5209f..ef22445 100644 --- a/components/tutorial/TutorialIntroPage.tsx +++ b/components/tutorial/TutorialIntroPage.tsx @@ -12,6 +12,7 @@ import { import BlockListView from '@/components/BlockListView'; import Text from '@/components/Text'; import CompareInstructions from '@/components/tutorial/CompareInstructions'; +import LocateInstructions from '@/components/tutorial/LocateInstructions'; import TileGridInstructions from '@/components/tutorial/TileGridInstructions'; import ValidateInstructions from '@/components/tutorial/ValidateInstructions'; import { @@ -20,10 +21,12 @@ import { SPACING_XS, } from '@/constants/dimensions'; import { + FbObjCustomOption, FbTutorial, PROJECT_TYPE_COMPARE, PROJECT_TYPE_COMPLETENESS, PROJECT_TYPE_FIND, + PROJECT_TYPE_LOCATE_FEATURES, PROJECT_TYPE_VALIDATE, PROJECT_TYPE_VALIDATE_IMAGE, } from '@/utils/types'; @@ -45,14 +48,19 @@ const styles = StyleSheet.create({ interface Props { tutorial: FbTutorial; + projectCustomOptions?: FbObjCustomOption[]; } function TutorialIntroPage(props: Props) { - const { tutorial } = props; + const { tutorial, projectCustomOptions } = props; const { t } = useTranslation(['instructionsScreen', 'tutorialScreen']); const instructionLine = (() => { - if (isTruthyString(tutorial.instruction)) { + if ( + 'instruction' in tutorial + && typeof tutorial.instruction === 'string' + && isTruthyString(tutorial.instruction) + ) { return tutorial.instruction; } if (isTruthyString(tutorial.lookFor)) { @@ -76,6 +84,8 @@ function TutorialIntroPage(props: Props) { ); } else if (tutorial.projectType === PROJECT_TYPE_COMPARE) { typeInstructions = ; + } else if (tutorial.projectType === PROJECT_TYPE_LOCATE_FEATURES) { + typeInstructions = ; } return ( diff --git a/components/tutorial/TutorialPager.tsx b/components/tutorial/TutorialPager.tsx index f6b8e0b..c2a104a 100644 --- a/components/tutorial/TutorialPager.tsx +++ b/components/tutorial/TutorialPager.tsx @@ -18,7 +18,7 @@ import { SPACING_3XS, SPACING_XS, } from '@/constants/dimensions'; -import { FbTutorial } from '@/utils/types'; +import { FbObjCustomOption, FbTutorial, Results } from '@/utils/types'; import StageIndicator from './StageIndicator'; import TutorialInformationPage from './TutorialInformationPage'; @@ -54,15 +54,16 @@ interface Props { currentIndex: number; onIndexChange: (index: number) => void; canAdvanceFrom: (index: number) => boolean; - scenarioResults: Record>; + scenarioResults: Record; onScenarioResultsChange: ( screenIndex: number, - next: Record | ((prev: Record) => Record), + next: Results | ((prev: Results) => Results), ) => void; scenarioStates: Record; attemptCounts: Record; onScenarioSubmit: (screenIndex: number, correct: boolean) => void; onScenarioShowAnswers: (screenIndex: number) => void; + projectCustomOptions?: FbObjCustomOption[]; } function TutorialPager(props: Props) { @@ -79,6 +80,7 @@ function TutorialPager(props: Props) { attemptCounts, onScenarioSubmit, onScenarioShowAnswers, + projectCustomOptions, } = props; const { width: pageWidth } = useWindowDimensions(); @@ -99,7 +101,12 @@ function TutorialPager(props: Props) { let content: React.ReactNode = null; if (item.type === 'intro') { - content = ; + content = ( + + ); } else if (item.type === 'info') { content = ; } else if (item.type === 'outro') { @@ -127,6 +134,7 @@ function TutorialPager(props: Props) { attempts={attempts} onScenarioSubmit={onScenarioSubmit} onScenarioShowAnswers={onScenarioShowAnswers} + projectCustomOptions={projectCustomOptions} /> ); } @@ -146,6 +154,7 @@ function TutorialPager(props: Props) { onScenarioResultsChange, onScenarioSubmit, onScenarioShowAnswers, + projectCustomOptions, ]); const scrollEnabled = canAdvanceFrom(currentIndex); diff --git a/components/tutorial/TutorialScenarioPage.tsx b/components/tutorial/TutorialScenarioPage.tsx index 3576aba..3e702c8 100644 --- a/components/tutorial/TutorialScenarioPage.tsx +++ b/components/tutorial/TutorialScenarioPage.tsx @@ -19,16 +19,19 @@ import { TUTORIAL_MAX_ATTEMPTS, } from '@/utils/tutorial'; import { + FbObjCustomOption, FbScreen, FbTutorial, PROJECT_TYPE_COMPARE, PROJECT_TYPE_COMPLETENESS, PROJECT_TYPE_FIND, + PROJECT_TYPE_LOCATE_FEATURES, PROJECT_TYPE_VALIDATE, Results, } from '@/utils/types'; import CompareTutorialSession from './CompareTutorialSession'; +import LocateTutorialSession from './LocateTutorialSession'; import ScenarioFeedback from './ScenarioFeedback'; import TileGridTutorialSession from './TileGridTutorialSession'; import { ScenarioState } from './types'; @@ -60,6 +63,7 @@ interface Props { attempts: number; onScenarioSubmit: (screenIndex: number, correct: boolean) => void; onScenarioShowAnswers: (screenIndex: number) => void; + projectCustomOptions?: FbObjCustomOption[]; } function TutorialScenarioPage(props: Props) { @@ -74,6 +78,7 @@ function TutorialScenarioPage(props: Props) { attempts, onScenarioSubmit, onScenarioShowAnswers, + projectCustomOptions, } = props; const { t } = useTranslation('tutorialScreen'); @@ -100,10 +105,10 @@ function TutorialScenarioPage(props: Props) { }, [tutorial.projectType, tasks, results, attempts, screenIndex, onScenarioSubmit, t]); const handleShowAnswers = useCallback(() => { - const referenceResults = getReferenceResults(tasks); + const referenceResults = getReferenceResults(tasks, tutorial.projectType); onScenarioResultsChange(screenIndex, (prev) => ({ ...prev, ...referenceResults })); onScenarioShowAnswers(screenIndex); - }, [tasks, screenIndex, onScenarioResultsChange, onScenarioShowAnswers]); + }, [tasks, tutorial.projectType, screenIndex, onScenarioResultsChange, onScenarioShowAnswers]); const disabled = state === 'correct' || state === 'skip-unlocked' @@ -145,6 +150,18 @@ function TutorialScenarioPage(props: Props) { /> ); break; + case PROJECT_TYPE_LOCATE_FEATURES: + session = ( + + ); + break; default: session = ; break; diff --git a/components/tutorial/types.ts b/components/tutorial/types.ts index 9125cbe..78b7dc6 100644 --- a/components/tutorial/types.ts +++ b/components/tutorial/types.ts @@ -1,6 +1,7 @@ import { AnyTutorialTask } from '@/utils/tutorial'; import { FbInformationPage, + FbObjCustomOption, FbScreen, FbTutorial, Results, @@ -30,4 +31,5 @@ export interface TutorialSessionProps { results: Results; onResultsChange: (next: Results | ((prev: Results) => Results)) => void; disabled: boolean; + projectCustomOptions?: FbObjCustomOption[]; } diff --git a/constants/common.ts b/constants/common.ts index e3600a4..53f923c 100644 --- a/constants/common.ts +++ b/constants/common.ts @@ -2,6 +2,7 @@ import { PROJECT_TYPE_COMPARE, PROJECT_TYPE_COMPLETENESS, PROJECT_TYPE_FIND, + PROJECT_TYPE_LOCATE_FEATURES, PROJECT_TYPE_VALIDATE, PROJECT_TYPE_VALIDATE_IMAGE, } from '@/utils/types'; @@ -12,6 +13,7 @@ export const SUPPORTED_PROJECT_TYPES = [ PROJECT_TYPE_COMPLETENESS, PROJECT_TYPE_VALIDATE, PROJECT_TYPE_VALIDATE_IMAGE, + PROJECT_TYPE_LOCATE_FEATURES, ]; export const managerDashboardUrl = process.env.EXPO_PUBLIC_MANAGER_DASHBOARD_URL; diff --git a/public/locales/en/instructionsScreen.json b/public/locales/en/instructionsScreen.json index 19838f7..bdc2fcb 100644 --- a/public/locales/en/instructionsScreen.json +++ b/public/locales/en/instructionsScreen.json @@ -24,5 +24,11 @@ "compareHoldZoom": "If you need to see an image more closely, tap and hold the image and it'll zoom in a little more.", "compareHint": "Hint", "compareDifferentImagery": "Sometimes different imagery sources will have been used. The images may be aligned slightly differently or might be a different resolution. Remember, you're looking for <1>definite changes in settlements and buildings — so if it looks like the same buildings are there but maybe there's a new roof, that's a 'no change' scenario and you'd simply swipe to the next image.", - "compareSwipeForMore": "Swipe to see additional information" + "compareSwipeForMore": "Swipe to see additional information", + "locateIntro": "This tutorial will teach you how to use MapSwipe. Here are the basic steps:", + "locateSwipe": "If there is nothing relevant in the images, simply swipe to the next screen.", + "locateTapReset": "Tap again to return the square to its original state.", + "locateMultiSelectSection": "Want to mark several tiles quickly?", + "locateMultiSelectDrag": "Tap the multi-select button, then drag across tiles to select more.", + "locateMultiSelectConfirm": "After dragging the tiles, tap the confirm button to save the selection." } diff --git a/public/locales/en/mappingSession.json b/public/locales/en/mappingSession.json index 5ee52e1..f16a63c 100644 --- a/public/locales/en/mappingSession.json +++ b/public/locales/en/mappingSession.json @@ -11,5 +11,8 @@ "goBack": "Go back", "goBackHelp": "Return to the mapping session to review or change your answers.", "discardSession": "Discard session", - "discardSessionHelp": "Throw away your answers from this session and return to the home screen." + "discardSessionHelp": "Throw away your answers from this session and return to the home screen.", + "enterSelectionMode": "Enter selection mode", + "cycleSelectedCells": "Cycle value of selected cells", + "exitSelectionMode": "Exit selection mode" } diff --git a/utils/firebase-generated-types.ts b/utils/firebase-generated-types.ts new file mode 100644 index 0000000..8180480 --- /dev/null +++ b/utils/firebase-generated-types.ts @@ -0,0 +1,557 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable max-len */ +import type { firestore } from 'firebase-admin'; + +/** Represents app announcements for the contributors. */ +export interface FbAnnouncement { + url: string; + text: string; +} + +/** Represents the requesting organisation. */ +export interface FbOrganisation { + name: string; + description?: string; + nameKey: string; + abbreviation?: string; + isArchived: boolean; +} + +/** Represents project status */ +export type FbEnumProjectStatus = + | 'active' + | 'inactive' + | 'private_inactive' + | 'private_active' + | 'finished' + | 'private_finished'; + +/** Represents project type */ +export type FbEnumProjectType = 1 | 2 | 10 | 3 | 4 | 7 | 9; + +/** Represents project fields that cannot be updated from backend */ +export interface FbProjectReadonlyType { + resultCount: number; +} + +/** Represents project fields that are valid while updating a project stats */ +export interface FbProjectUpdateStatsInput { + contributorCount: number; + progress: number; +} + +/** Represents project fields that are valid while updating a project */ +export interface FbProjectUpdateInput { + image?: string; + isFeatured: boolean; + lookFor?: string; + projectInstruction?: string; + name: string; + projectDetails: string; + projectNumber: number; + projectRegion: string; + projectTopic: string; + projectTopicKey: string; + requestingOrganisation: string; + tutorialId: string; + language: string; + manualUrl?: string; + teamId?: string; + status: FbEnumProjectStatus; + maxTasksPerUser?: number; + contributorCount: number; + progress: number; +} + +/** Represents project fields that are valid while creating a project */ +export interface FbProjectCreateOnlyInput { + created: firestore.Timestamp; + createdBy: string; + groupMaxSize: number; + groupSize: number; + projectId: string; + projectType: FbEnumProjectType; + requiredResults: number; + verificationNumber: number; +} + +/** Represents mapping group fields that cannot be updated from backend */ +export interface FbMappingGroupReadonlyType { + finishedCount: number; + progress: number; +} + +/** Represents mapping group fields that are valid while creating a mapping group */ +export interface FbMappingGroupCreateOnlyInput { + projectId: string; + numberOfTasks: number; + requiredCount: number; +} + +/** Represents mapping task fields that are valid while creating a task */ +export interface FbMappingTaskCreateOnlyInput { + projectId: string; +} + +/** Represents a mapswipe project */ +export interface FbMappingResult { + appVersion: string; + clientType?: string; + endTime: firestore.Timestamp; + startTime: firestore.Timestamp; + results?: Record; + usergroups?: Record; +} + +/** Represents a custom sub-option */ +export interface FbBaseObjCustomSubOption { + value: number; + description: string; +} + +/** Represents a custom option */ +export interface FbObjCustomOption { + value: number; + title: string; + description: string; + icon: string; + iconColor: string; + subOptions?: FbBaseObjCustomSubOption[]; +} + +/** Represents COMPARE project fields that are valid while creating a project */ +export interface FbProjectCompareCreateOnlyInput { + zoomLevel: number; + tileServer: FbObjRasterTileServer; + tileServerB: FbObjRasterTileServer; +} + +/** Represents COMPARE mapping task fields that are valid while creating a task */ +export interface FbMappingTaskCompareCreateOnlyInput { + groupId: string; + taskId: string; + taskX?: number; + taskY?: number; + url?: string; + urlB?: string; +} + +export type FbEnumOverlayTileServerType = 'raster' | 'vector'; + +/** Represents an overlay layer */ +export interface FbObjUnifiedOverlayTileServer { + type: FbEnumOverlayTileServerType; + raster?: FbObjRasterTileServerOverlay; + vector?: FbObjVectorTileServerOverlay; +} + +/** Represents COMPLETNESS project fields that are valid while creating a project */ +export interface FbProjectCompletenessCreateOnlyInput { + zoomLevel: number; + tileServer: FbObjRasterTileServer; + tileServerB: FbObjRasterTileServer; + overlayTileServer: FbObjUnifiedOverlayTileServer; +} + +/** Represents FIND project fields that are valid while creating a project */ +export interface FbProjectFindCreateOnlyInput { + zoomLevel: number; + tileServer: FbObjRasterTileServer; +} + +export type FBEnumSubGridSize = '2x2' | '4x4' | '8x8'; + +/** Represents LOCATE project fields that are valid while creating a project */ +export interface FbProjectLocateCreateOnlyInput { + zoomLevel: number; + tileServer: FbObjRasterTileServer; + subGridSize: FBEnumSubGridSize; + customOptions?: FbObjCustomOption[]; + exportMetaKey: string; + exportMetaValue: string; +} + +/** Represents LOCATE mapswipe project results */ +export interface FbProjectLocateMappingResult { + appVersion: string; + clientType?: string; + endTime: firestore.Timestamp; + startTime: firestore.Timestamp; + results?: Record; + usergroups?: Record; +} + +/** Represents STREET project fields that are valid while creating a project */ +export interface FbProjectStreetCreateOnlyInput { + customOptions?: FbObjCustomOption[]; + numberOfGroups: number; +} + +/** Represents STREET mapping group fields that are valid while creating a mapping group */ +export interface FbMappingGroupStreetCreateOnlyInput { + groupId: string; +} + +/** Represents STREET mapping task fields that are valid while creating a task */ +export interface FbMappingTaskStreetCreateOnlyInput { + taskId: number; + groupId: string; +} + +/** Represents TILE_MAP_SERVICE mapping group fields that are valid while creating a mapping group */ +export interface FbMappingGroupTileMapServiceCreateOnlyInput { + groupId: string; + xMax: number; + xMin: number; + yMax: number; + yMin: number; +} + +export type FbEnumValidateInputType = 'aoi_file' | 'link' | 'TMId'; + +/** Represents VALIDATE project fields that are valid while creating a project */ +export interface FbProjectValidateCreateOnlyInput { + customOptions?: FbObjCustomOption[]; + tileServer: FbObjRasterTileServer; + inputType: FbEnumValidateInputType; + filter?: string; + TMId?: string; +} + +/** Represents VALIDATE mapping group fields that are valid while creating a mapping group */ +export interface FbMappingGroupValidateCreateOnlyInput { + groupId: string; +} + +/** Represents VALIDATE mapping task fields that are valid while creating a task */ +export interface FbMappingTaskValidateCreateOnlyInput { + taskId: string; + geojson: Record; +} + +export type FbEnumValidateImageInputType = 'direct_images' | 'dataset_file'; + +/** Represents VALIDATE_IMAGE project fields that are valid while creating a project */ +export interface FbProjectValidateImageCreateOnlyInput { + customOptions?: FbObjCustomOption[]; +} + +/** Represents VALIDATE_IMAGE mapping group fields that are valid while creating a mapping group */ +export interface FbMappingGroupValidateImageCreateOnlyInput { + groupId: string; +} + +/** Represents VALIDATE_IMAGE mapping task fields that are valid while creating a task */ +export interface FbMappingTaskValidateImageCreateOnlyInput { + taskId: string; + url: string; + fileName: string; + width?: number; + height?: number; + annotationId?: string; + bbox?: number[]; + segmentation?: number[][]; +} + +/** Represents supported raster tile server */ +export type FbEnumRasterTileServerName = + | 'custom' + | 'bing' + | 'mapbox' + | 'maxarStandard' + | 'maxarPremium' + | 'esri' + | 'esriBeta'; + +/** Represents a raster tile server configuration */ +export interface FbObjRasterTileServer { + apiKey?: string; + wmtsLayerName?: string; + credits: string; + name: FbEnumRasterTileServerName; + url: string; +} + +/** Represents an overlay layer for raster layer */ +export interface FbObjRasterTileServerOverlay { + tileServer: FbObjRasterTileServer; + opacity: number; +} + +/** Represents supported vector tile server */ +export type FbEnumVectorTileServerName = 'custom' | 'openStreetMap' | 'openFreeMap' | 'versatiles'; + +/** Represents a vector tile server configuration */ +export interface FbObjVectorTileServer { + credits: string; + name: FbEnumVectorTileServerName; + sourceLayer: string; + url: string; + minZoom: number; + maxZoom: number; +} + +/** Represents an overlay layer for vector layer */ +export interface FbObjVectorTileServerOverlay { + tileServer: FbObjVectorTileServer; + fillColor: string; + fillOpacity: number; + lineColor: string; + lineOpacity: number; + lineWidth: number; + lineDasharray: number[]; + circleColor: string; + circleOpacity: number; + circleRadius: number; +} + +/** Represents a team to limit project visibility. */ +export interface FbTeam { + teamName: string; + teamToken: string; + isArchived: boolean; +} + +export type FbEnumInformationPageBlockType = 'text' | 'image'; + +export interface FbInformationPageBlock { + blockNumber: number; + blockType: FbEnumInformationPageBlockType; + textDescription?: string; + image?: string; +} + +export interface FbInformationPage { + pageNumber: number; + title: string; + blocks?: FbInformationPageBlock[]; +} + +export interface FbScreenBlock { + title: string; + description: string; + icon: string; +} + +export interface FbScreen { + hint: FbScreenBlock; + instructions: FbScreenBlock; + success: FbScreenBlock; +} + +export interface FbBaseTutorial { + exampleImage1?: string; + exampleImage2?: string; + contributorCount: number; + informationPages?: FbInformationPage[]; + lookFor?: string; + name: string; + progress: number; + projectDetails: string; + projectId: string; + projectTopicKey: string; + status: 'tutorial'; + tutorialDraftId: string; + screens?: FbScreen[]; +} + +export interface FbBaseTutorialGroup { + finishedCount: number; + groupId: number; + numberOfTasks: number; + progress: number; + projectId: string; + requiredCount: number; +} + +export interface FbCompareTutorial { + projectType: 3; + tileServer: FbObjRasterTileServer; + tileServerB: FbObjRasterTileServer; + zoomLevel: number; +} + +export interface FbCompareTutorialTask { + url: string; + urlB: string; +} + +export interface FbCompletenessTutorial { + projectType: 4; + tileServer: FbObjRasterTileServer; + tileServerB: FbObjRasterTileServer; + overlayTileServer: FbObjUnifiedOverlayTileServer; + zoomLevel: number; +} + +export interface FbCompletenessTutorialTask { + url: string; + urlB: string; +} + +export interface FbFindTutorial { + projectType: 1; + tileServer: FbObjRasterTileServer; + zoomLevel: number; +} + +export interface FbFindTutorialTask { + url: string; +} + +export interface FbLocateTutorial { + projectType: 9; + tileServer: FbObjRasterTileServer; + subGridSize: FBEnumSubGridSize; + zoomLevel: number; +} + +export interface FbLocateTutorialTask { + url: string; +} + +export interface FbStreetTutorial { + projectType: 7; + customOptions?: FbObjCustomOption[]; +} + +export interface FbStreetTutorialTask { + projectId: string; + groupId: number; + taskId: string; + geometry: string; + referenceAnswer: number; + screen: number; +} + +export interface FbTileMapServiceTutorialGroup { + xMax: number; + xMin: number; + yMax: number; + yMin: number; +} + +export interface FbTileMapServiceTutorialTask { + geometry: string; + groupId: number; + projectId: string; + referenceAnswer: number; + taskPartitionIndex?: number; + screen: number; + taskId: string; + taskId_real: string; + taskX: number; + taskY: number; +} + +export interface FbValidateTutorial { + inputGeometries: string; + projectType: 2; + tileServer: FbObjRasterTileServer; + zoomLevel: number; + customOptions?: FbObjCustomOption[]; +} + +export interface FbValidateTutorialTaskProperties { + id: number; + screen: number; + reference: number; +} + +export interface FbValidateTutorialTask { + taskId: string; + geojson: unknown; + properties: FbValidateTutorialTaskProperties; + geometry: string; +} + +export interface FbValidateImageTutorial { + projectType: 10; + customOptions?: FbObjCustomOption[]; +} + +export interface FbValidateImageTutorialTask { + groupId: number; + projectId: string; + referenceAnswer: number; + screen: number; + geometry: string; + taskId: string; + fileName: string; + url: string; + width?: number; + height?: number; + annotationId?: string; + bbox?: number[]; + segmentation?: number[][]; +} + +/** Represents user fields that cannot be updated from backend */ +export interface FbUserReadonlyType { + created: firestore.Timestamp; + lastAppUse?: firestore.Timestamp; + userName?: string; + userNameKey?: string; + username?: string; + usernameKey?: string; + accessibility?: boolean; + userGroups?: Record; + contributions?: Record; + taskContributionCount?: number; + groupContributionCount?: number; + projectContributionCount?: number; +} + +/** Represents a user */ +export interface FbUserUpdateInput { + teamId?: string; +} + +/** Represents a user contribution */ +export interface FbUserContribution { + endTime: firestore.Timestamp; + startTime: firestore.Timestamp; + timestamp: firestore.Timestamp; +} + +export type FbEnumUserGroupMembershipAction = 'join' | 'leave'; + +/** Represents a usergroup */ +export interface FbUserGroupReadOnlyType { + users?: Record; +} + +/** Represents a usergroup */ +export interface FbUserGroupCreateOnlyInput { + createdAt: number; + createdBy: string; +} + +/** Represents a usergroup */ +export interface FbUserGroupUpdateInput { + description: string; + name: string; + nameKey: string; + archivedAt?: number; + archivedBy?: string; +} + +/** Represents a usergroup */ +export interface FbUserGroupObsolete { + name: string; + description: string; +} + +/** Represents a user contribution */ +export interface FbUserGroupMembership { + action: FbEnumUserGroupMembershipAction; + timestamp: number; + userGroupId: string; + userId: string; +} + +/** Represents if to wait for firebase. */ +export interface FbBackendWait { + ok: boolean; + timestamp: firestore.Timestamp; +} diff --git a/utils/task.ts b/utils/task.ts index d4ae0fd..2138366 100644 --- a/utils/task.ts +++ b/utils/task.ts @@ -3,6 +3,7 @@ import { FbMappingGroupTileMapServiceCreateOnlyInput, FbObjRasterTileServer, FindProject, + LocateFeaturesProject, PROJECT_TYPE_COMPLETENESS, TileTask, } from './types'; @@ -86,7 +87,7 @@ export const getTileUrlFromCoordsAndTileserver = ( }; export const buildTasks = ( - project: FindProject | CompletenessProject, + project: FindProject | CompletenessProject | LocateFeaturesProject, group: FbMappingGroupTileMapServiceCreateOnlyInput, ) => { const xArray = arrayFromMinMax(group.xMin, group.xMax); diff --git a/utils/tutorial.ts b/utils/tutorial.ts index fe94e4c..85e27b7 100644 --- a/utils/tutorial.ts +++ b/utils/tutorial.ts @@ -10,6 +10,7 @@ import { FbTileMapServiceTutorialTask, FbValidateImageTutorialTask, FbValidateTutorialTask, + PROJECT_TYPE_LOCATE_FEATURES, PROJECT_TYPE_STREET, PROJECT_TYPE_VALIDATE_IMAGE, Results, @@ -85,7 +86,45 @@ function getReferenceForTask(task: AnyTutorialTask): number | undefined { return undefined; } -export function getReferenceResults(tasks: AnyTutorialTask[]): Results { +// Locate features stores results as Record keyed by the +// tile-level id (taskId_real). Each per-cell tutorial task carries a scalar +// referenceAnswer plus a taskPartitionIndex telling us which array slot it +// belongs to. Returns undefined for tasks that aren't locate-shaped. +function getLocatePartition( + task: AnyTutorialTask, +): { tileKey: string; partitionIndex: number } | undefined { + if (!('taskId_real' in task) || typeof task.taskId_real !== 'string') { + return undefined; + } + if (!('taskPartitionIndex' in task) || typeof task.taskPartitionIndex !== 'number') { + return undefined; + } + return { tileKey: task.taskId_real, partitionIndex: task.taskPartitionIndex }; +} + +export function getReferenceResults( + tasks: AnyTutorialTask[], + projectType?: number, +): Results { + if (projectType === PROJECT_TYPE_LOCATE_FEATURES) { + const tileResults: Record = {}; + tasks.forEach((task) => { + const reference = getReferenceForTask(task); + const partition = getLocatePartition(task); + if (reference === undefined || partition === undefined) { + return; + } + const { tileKey, partitionIndex } = partition; + if (!tileResults[tileKey]) { + tileResults[tileKey] = []; + } + while (tileResults[tileKey].length <= partitionIndex) { + tileResults[tileKey].push(0); + } + tileResults[tileKey][partitionIndex] = reference; + }); + return tileResults; + } const result: Results = {}; tasks.forEach((task) => { const reference = getReferenceForTask(task); @@ -107,6 +146,23 @@ export function isScenarioCorrect( if (tasks.length === 0) { return true; } + if (projectType === PROJECT_TYPE_LOCATE_FEATURES) { + return tasks.every((task) => { + const reference = getReferenceForTask(task); + if (isNotDefined(reference)) { + return true; + } + const partition = getLocatePartition(task); + if (partition === undefined) { + return true; + } + const cells = results[partition.tileKey]; + if (!Array.isArray(cells)) { + return false; + } + return cells[partition.partitionIndex] === reference; + }); + } return tasks.every((task) => { const reference = getReferenceForTask(task); if (isNotDefined(reference)) { diff --git a/utils/types.ts b/utils/types.ts index 0c6655e..afe2813 100644 --- a/utils/types.ts +++ b/utils/types.ts @@ -1,528 +1,45 @@ -import type { Timestamp } from 'firebase/firestore'; - -/** Represents app announcements for the contributors. */ -export interface FbAnnouncement { - url: string; - text: string; -} - -/** Represents the requesting organisation. */ -export interface FbOrganisation { - name: string; - description?: string; - nameKey: string; - abbreviation?: string; - isArchived: boolean; -} - -/** Represents project status */ -export type FbEnumProjectStatus = - | 'active' - | 'inactive' - | 'private_inactive' - | 'private_active' - | 'finished' - | 'private_finished'; - -/** Represents project type */ -export type FbEnumProjectType = 1 | 2 | 10 | 3 | 4 | 7; - -/** Represents project fields that cannot be updated from backend */ -export interface FbProjectReadonlyType { - resultCount: number; -} - -/** Represents project fields that are valid while updating a project stats */ -export interface FbProjectUpdateStatsInput { - contributorCount: number; - progress: number; -} - -/** Represents project fields that are valid while updating a project */ -export interface FbProjectUpdateInput { - image?: string; - isFeatured: boolean; - lookFor?: string; - instruction?: string; - projectInstruction?: string; - name: string; - projectDetails: string; - projectNumber: number; - projectRegion: string; - projectTopic: string; - projectTopicKey: string; - requestingOrganisation: string; - tutorialId: string; - language: string; - manualUrl?: string; - teamId?: string; - status: FbEnumProjectStatus; - maxTasksPerUser?: number; - contributorCount: number; - progress: number; -} - -/** Represents project fields that are valid while creating a project */ -export interface FbProjectCreateOnlyInput { - created: Timestamp; - createdBy: string; - groupMaxSize: number; - groupSize: number; - projectId: string; - projectType: FbEnumProjectType; - requiredResults: number; - verificationNumber: number; -} - -/** Represents mapping group fields that cannot be updated from backend */ -export interface FbMappingGroupReadonlyType { - finishedCount: number; - progress: number; -} - -/** Represents mapping group fields that are valid while creating a mapping group */ -export interface FbMappingGroupCreateOnlyInput { - projectId: string; - numberOfTasks: number; - requiredCount: number; -} - -/** Represents mapping task fields that are valid while creating a task */ -export interface FbMappingTaskCreateOnlyInput { - projectId: string; -} - -/** Represents a mapswipe project */ -export interface FbMappingResult { - appVersion: string; - clientType?: string; - endTime: Timestamp; - startTime: Timestamp; - results?: Record; - usergroups?: Record; -} - -/** Represents a custom sub-option */ -export interface FbBaseObjCustomSubOption { - value: number; - description: string; -} - -/** Represents a custom option */ -export interface FbObjCustomOption { - value: number; - title: string; - description: string; - icon: string; - iconColor: string; - subOptions?: FbBaseObjCustomSubOption[]; -} - -/** Represents COMPARE project fields that are valid while creating a project */ -export interface FbProjectCompareCreateOnlyInput { - zoomLevel: number; - tileServer: FbObjRasterTileServer; - tileServerB: FbObjRasterTileServer; -} - -/** Represents COMPARE mapping task fields that are valid while creating a task */ -export interface FbMappingTaskCompareCreateOnlyInput { - groupId: string; - taskId: string; - taskX?: number; - taskY?: number; - url?: string; - urlB?: string; -} - -export type FbEnumOverlayTileServerType = 'raster' | 'vector'; - -/** Represents an overlay layer */ -export interface FbObjUnifiedOverlayTileServer { - type: FbEnumOverlayTileServerType; - raster?: FbObjRasterTileServerOverlay; - vector?: FbObjVectorTileServerOverlay; -} - -/** Represents COMPLETNESS project fields that are valid while creating a project */ -export interface FbProjectCompletenessCreateOnlyInput { - zoomLevel: number; - tileServer: FbObjRasterTileServer; - tileServerB: FbObjRasterTileServer; - overlayTileServer: FbObjUnifiedOverlayTileServer; -} - -/** Represents FIND project fields that are valid while creating a project */ -export interface FbProjectFindCreateOnlyInput { - zoomLevel: number; - tileServer: FbObjRasterTileServer; -} - -/** Represents STREET project fields that are valid while creating a project */ -export interface FbProjectStreetCreateOnlyInput { - customOptions?: FbObjCustomOption[]; - numberOfGroups: number; -} - -/** Represents STREET mapping group fields that are valid while creating a mapping group */ -export interface FbMappingGroupStreetCreateOnlyInput { - groupId: string; -} - -/** Represents STREET mapping task fields that are valid while creating a task */ -export interface FbMappingTaskStreetCreateOnlyInput { - taskId: number; - groupId: string; -} - -/** Represents TILE_MAP_SERVICE mapping group fields - * that are valid while creating a mapping group */ -export interface FbMappingGroupTileMapServiceCreateOnlyInput { - groupId: string; - xMax: number; - xMin: number; - yMax: number; - yMin: number; -} - -export type FbEnumValidateInputType = 'aoi_file' | 'link' | 'TMId'; - -/** Represents VALIDATE project fields that are valid while creating a project */ -export interface FbProjectValidateCreateOnlyInput { - customOptions?: FbObjCustomOption[]; - tileServer: FbObjRasterTileServer; - inputType: FbEnumValidateInputType; - filter?: string; - TMId?: string; -} - -/** Represents VALIDATE mapping group fields that are valid while creating a mapping group */ -export interface FbMappingGroupValidateCreateOnlyInput { - groupId: string; -} - -/** Represents VALIDATE mapping task fields that are valid while creating a task */ -export interface FbMappingTaskValidateCreateOnlyInput { - taskId: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - geojson: Record; -} - -export type FbEnumValidateImageInputType = 'direct_images' | 'dataset_file'; - -/** Represents VALIDATE_IMAGE project fields that are valid while creating a project */ -export interface FbProjectValidateImageCreateOnlyInput { - customOptions?: FbObjCustomOption[]; -} - -/** Represents VALIDATE_IMAGE mapping group fields that are valid while creating a mapping group */ -export interface FbMappingGroupValidateImageCreateOnlyInput { - groupId: string; -} - -/** Represents VALIDATE_IMAGE mapping task fields that are valid while creating a task */ -export interface FbMappingTaskValidateImageCreateOnlyInput { - taskId: string; - url: string; - fileName: string; - width?: number; - height?: number; - annotationId?: string; - bbox?: number[]; - segmentation?: number[][]; -} - -/** Represents supported raster tile server */ -export type FbEnumRasterTileServerName = - | 'custom' - | 'bing' - | 'mapbox' - | 'maxarStandard' - | 'maxarPremium' - | 'esri' - | 'esriBeta'; - -/** Represents a raster tile server configuration */ -export interface FbObjRasterTileServer { - apiKey?: string; - wmtsLayerName?: string; - credits: string; - name: FbEnumRasterTileServerName; - url: string; -} - -/** Represents an overlay layer for raster layer */ -export interface FbObjRasterTileServerOverlay { - tileServer: FbObjRasterTileServer; - opacity: number; -} - -/** Represents supported vector tile server */ -export type FbEnumVectorTileServerName = 'custom' | 'openStreetMap' | 'openFreeMap' | 'versatiles'; - -/** Represents a vector tile server configuration */ -export interface FbObjVectorTileServer { - credits: string; - name: FbEnumVectorTileServerName; - sourceLayer: string; - url: string; - minZoom: number; - maxZoom: number; -} - -/** Represents an overlay layer for vector layer */ -export interface FbObjVectorTileServerOverlay { - tileServer: FbObjVectorTileServer; - fillColor: string; - fillOpacity: number; - lineColor: string; - lineOpacity: number; - lineWidth: number; - lineDasharray: number[]; - circleColor: string; - circleOpacity: number; - circleRadius: number; -} - -/** Represents a team to limit project visibility. */ -export interface FbTeam { - teamName: string; - teamToken: string; - isArchived: boolean; -} - -export type FbEnumInformationPageBlockType = 'text' | 'image'; - -export interface FbInformationPageBlock { - blockNumber: number; - blockType: FbEnumInformationPageBlockType; - textDescription?: string; - image?: string; -} - -export interface FbInformationPage { - pageNumber: number; - title: string; - blocks?: FbInformationPageBlock[]; -} - -export interface FbScreenBlock { - title: string; - description: string; - icon: string; -} - -export interface FbScreen { - hint: FbScreenBlock; - instructions: FbScreenBlock; - success: FbScreenBlock; -} - -export interface FbBaseTutorial { - exampleImage1?: string; - exampleImage2?: string; - contributorCount: number; - informationPages?: FbInformationPage[]; - lookFor?: string; - instruction?: string; - name: string; - progress: number; - projectDetails: string; - projectId: string; - projectTopicKey: string; - status: 'tutorial'; - tutorialDraftId: string; - screens?: FbScreen[]; -} - -export interface FbBaseTutorialGroup { - finishedCount: number; - groupId: number; - numberOfTasks: number; - progress: number; - projectId: string; - requiredCount: number; -} - -export interface FbCompareTutorial { - projectType: 3; - tileServer: FbObjRasterTileServer; - tileServerB: FbObjRasterTileServer; - zoomLevel: number; -} - -export interface FbCompareTutorialTask { - url: string; - urlB: string; -} - -export interface FbCompletenessTutorial { - projectType: 4; - tileServer: FbObjRasterTileServer; - tileServerB: FbObjRasterTileServer; - overlayTileServer: FbObjUnifiedOverlayTileServer; - zoomLevel: number; -} - -export interface FbCompletenessTutorialTask { - url: string; - urlB: string; -} - -export interface FbFindTutorial { - projectType: 1; - tileServer: FbObjRasterTileServer; - zoomLevel: number; -} - -export interface FbFindTutorialTask { - url: string; -} - -export interface FbStreetTutorial { - projectType: 7; - customOptions?: FbObjCustomOption[]; -} - -export interface FbStreetTutorialTask { - projectId: string; - groupId: number; - taskId: string; - geometry: string; - referenceAnswer: number; - screen: number; -} - -export interface FbTileMapServiceTutorialGroup { - xMax: number; - xMin: number; - yMax: number; - yMin: number; -} - -export interface FbTileMapServiceTutorialTask { - geometry: string; - groupId: number; - projectId: string; - referenceAnswer: number; - screen: number; - taskId: string; - taskId_real: string; - taskX: number; - taskY: number; -} - -export interface FbValidateTutorial { - inputGeometries: string; - projectType: 2; - tileServer: FbObjRasterTileServer; - zoomLevel: number; - customOptions?: FbObjCustomOption[]; -} - -export interface FbValidateTutorialTaskProperties { - id: number; - screen: number; - reference: number; -} - -export interface FbValidateTutorialTask { - taskId: string; - geojson: unknown; - properties: FbValidateTutorialTaskProperties; - geometry: string; -} - -export interface FbValidateImageTutorial { - projectType: 10; - customOptions?: FbObjCustomOption[]; -} - -export interface FbValidateImageTutorialTask { - groupId: number; - projectId: string; - referenceAnswer: number; - screen: number; - geometry: string; - taskId: string; - fileName: string; - url: string; - width?: number; - height?: number; - annotationId?: string; - bbox?: number[]; - segmentation?: number[][]; -} - -/** Represents user fields that cannot be updated from backend */ -export interface FbUserReadonlyType { - created: Timestamp; - lastAppUse?: Timestamp; - userName?: string; - userNameKey?: string; - username?: string; - usernameKey?: string; - accessibility?: boolean; - userGroups?: Record; - contributions?: Record; - taskContributionCount?: number; - groupContributionCount?: number; - projectContributionCount?: number; -} - -/** Represents a user */ -export interface FbUserUpdateInput { - teamId?: string; -} - -/** Represents a user contribution */ -export interface FbUserContribution { - endTime: Timestamp; - startTime: Timestamp; - timestamp: Timestamp; -} - -export type FbEnumUserGroupMembershipAction = 'join' | 'leave'; - -/** Represents a usergroup */ -export interface FbUserGroupReadOnlyType { - users?: Record; -} - -/** Represents a usergroup */ -export interface FbUserGroupCreateOnlyInput { - createdAt: number; - createdBy: string; -} - -/** Represents a usergroup */ -export interface FbUserGroupUpdateInput { - description: string; - name: string; - nameKey: string; - archivedAt?: number; - archivedBy?: string; -} - -/** Represents a usergroup */ -export interface FbUserGroupObsolete { - name: string; - description: string; -} - -/** Represents a user contribution */ -export interface FbUserGroupMembership { - action: FbEnumUserGroupMembershipAction; - timestamp: number; - userGroupId: string; - userId: string; -} - -/** Represents if to wait for firebase. */ -export interface FbBackendWait { - ok: boolean; - timestamp: Timestamp; -} +import type { + FbBaseTutorial, + FbCompareTutorial, + FbCompletenessTutorial, + FbEnumProjectType, + FbFindTutorial, + FbLocateTutorial, + FbMappingTaskCompareCreateOnlyInput, + FbMappingTaskCreateOnlyInput, + FbMappingTaskStreetCreateOnlyInput, + FbMappingTaskValidateCreateOnlyInput, + FbMappingTaskValidateImageCreateOnlyInput, + FbProjectCompareCreateOnlyInput, + FbProjectCompletenessCreateOnlyInput, + FbProjectCreateOnlyInput, + FbProjectFindCreateOnlyInput, + FbProjectLocateCreateOnlyInput, + FbProjectStreetCreateOnlyInput, + FbProjectUpdateInput, + FbProjectValidateCreateOnlyInput, + FbProjectValidateImageCreateOnlyInput, + FbStreetTutorial, + FbValidateImageTutorial, + FbValidateTutorial, +} from './firebase-generated-types'; + +export type { + FbCompareTutorialTask, + FbCompletenessTutorialTask, + FbFindTutorialTask, + FbInformationPage, + FbMappingGroupTileMapServiceCreateOnlyInput, + FbMappingTaskCompareCreateOnlyInput, + FbObjCustomOption, + FbObjRasterTileServer, + FbScreen, + FbScreenBlock, + FbStreetTutorialTask, + FbTileMapServiceTutorialTask, + FbValidateImageTutorialTask, + FbValidateTutorialTask, +} from './firebase-generated-types'; export const PROJECT_TYPE_FIND = 1 satisfies FbEnumProjectType; export const PROJECT_TYPE_VALIDATE = 2 satisfies FbEnumProjectType; @@ -530,6 +47,7 @@ export const PROJECT_TYPE_COMPARE = 3 satisfies FbEnumProjectType; export const PROJECT_TYPE_COMPLETENESS = 4 satisfies FbEnumProjectType; export const PROJECT_TYPE_STREET = 7 satisfies FbEnumProjectType; export const PROJECT_TYPE_VALIDATE_IMAGE = 10 satisfies FbEnumProjectType; +export const PROJECT_TYPE_LOCATE_FEATURES = 9 satisfies FbEnumProjectType; type BaseProject = FbProjectCreateOnlyInput & FbProjectUpdateInput; @@ -558,12 +76,17 @@ export type ValidateImageProject = BaseProject & FbProjectValidateImageCreateOnl projectType: typeof PROJECT_TYPE_VALIDATE_IMAGE; }; +export type LocateFeaturesProject = BaseProject & FbProjectLocateCreateOnlyInput & { + projectType: typeof PROJECT_TYPE_LOCATE_FEATURES; +} + export type FbProject = FindProject | CompareProject | ValidateProject | CompletenessProject | StreetProject -| ValidateImageProject; +| ValidateImageProject +| LocateFeaturesProject; export type FbTutorial = FbBaseTutorial & ( FbFindTutorial @@ -572,6 +95,7 @@ export type FbTutorial = FbBaseTutorial & ( | FbValidateImageTutorial | FbCompletenessTutorial | FbStreetTutorial + | FbLocateTutorial ); export type ValidateTask = FbMappingTaskCreateOnlyInput & FbMappingTaskValidateCreateOnlyInput; @@ -592,7 +116,7 @@ export type FeatureGeoJson = GeoJSON.FeatureCollection | GeoJSON.Feature | GeoJSON.Geometry; -export type Results = Record; +export type Results = Record; export interface ResultOption { value: number; diff --git a/utils/urqlClient.ts b/utils/urqlClient.ts index 01c08ed..fd1da03 100644 --- a/utils/urqlClient.ts +++ b/utils/urqlClient.ts @@ -24,7 +24,7 @@ const csrfFetch: typeof fetch = async ( headers: { ...(init?.headers ?? {}), 'X-CSRFToken': csrfToken ?? '', - Referer: referrerEndpoint, + Referer: referrerEndpoint ?? '', }, }); }; From 6e18ed6489109d76eb001596d79679ec20d2a05f Mon Sep 17 00:00:00 2001 From: AdityaKhatri Date: Mon, 18 May 2026 11:15:22 +0545 Subject: [PATCH 2/2] feat: add showConfirm to make alert work on web --- app/(auth)/(home)/_layout.tsx | 7 ---- app/(auth)/(home)/profile.tsx | 52 ++++++++++---------------- app/(auth)/exploreGroup/[id]/index.tsx | 14 +++---- app/(auth)/project/[id]/index.tsx | 1 + app/(auth)/project/_layout.tsx | 4 +- app/login.tsx | 12 +++--- app/register.tsx | 12 +++--- components/Page/index.tsx | 27 +++++++------ components/showConfirm.ts | 49 ++++++++++++++++++++++++ 9 files changed, 102 insertions(+), 76 deletions(-) create mode 100644 components/showConfirm.ts diff --git a/app/(auth)/(home)/_layout.tsx b/app/(auth)/(home)/_layout.tsx index 59274fd..98dd2c0 100644 --- a/app/(auth)/(home)/_layout.tsx +++ b/app/(auth)/(home)/_layout.tsx @@ -75,13 +75,6 @@ function HomeLayout() { tabBarButton: (props) => , }} > - { - Alert.alert('Sign Out', 'Are you sure you want to sign out?', [ - { - text: 'Cancel', - style: 'cancel', - }, - { - text: 'OK', - style: 'destructive', - onPress: () => { - firebaseAuth.signOut().catch((error) => { - showAlert({ - title: 'Sign out error', - message: error, - alertType: 'error', - }); + showConfirm({ + title: 'Sign Out', + message: 'Are you sure you want to sign out?', + onConfirm: () => { + firebaseAuth.signOut().catch((error) => { + showAlert({ + title: 'Sign out error', + message: error, + alertType: 'error', }); - router.replace('/'); - }, + }); + router.replace('/'); }, - ]); + destructive: true, + }); }, [router]); const handleResetPress = useCallback(async () => { @@ -201,20 +196,11 @@ function Profile() { }, [handleAsync, user, t]); const handleResetPasswordClick = useCallback(() => { - Alert.alert( - 'Reset Password', - 'An email will be sent to your account with the reset link. Are you sure you want to continue?', - [ - { - text: 'Cancel', - style: 'cancel', - }, - { - text: 'OK', - onPress: handleResetPress, - }, - ], - ); + showConfirm({ + title: 'Reset Password', + message: 'An email will be sent to your account with the reset link. Are you sure you want to continue?', + onConfirm: handleResetPress, + }); }, [handleResetPress]); const settingItems: ButtonLayoutProps[] = [ diff --git a/app/(auth)/exploreGroup/[id]/index.tsx b/app/(auth)/exploreGroup/[id]/index.tsx index ff3e2f2..4c8ead0 100644 --- a/app/(auth)/exploreGroup/[id]/index.tsx +++ b/app/(auth)/exploreGroup/[id]/index.tsx @@ -4,7 +4,6 @@ import React, { } from 'react'; import { useTranslation } from 'react-i18next'; import { - Alert, Linking, RefreshControl, ScrollView, @@ -29,6 +28,7 @@ import Icon from '@/components/Icon'; import InfoCard, { StatsInfo } from '@/components/InfoCard'; import InlineListView from '@/components/InlineListView'; import Page from '@/components/Page'; +import showConfirm from '@/components/showConfirm'; import Text from '@/components/Text'; import { showAlert } from '@/components/Toast'; import { @@ -239,14 +239,10 @@ function ExploreGroup() { }); } }; - Alert.alert( - isJoin ? 'Join User Group' : 'Leave User Group', - message, - [ - { text: 'Cancel', style: 'cancel' }, - { text: 'OK', onPress: proceed }, - ], - ); + showConfirm({ + title: isJoin ? 'Join User Group' : 'Leave User Group', + onConfirm: proceed, + }); }, [userId, userGroupId, router], ); diff --git a/app/(auth)/project/[id]/index.tsx b/app/(auth)/project/[id]/index.tsx index 03ff25b..8639673 100644 --- a/app/(auth)/project/[id]/index.tsx +++ b/app/(auth)/project/[id]/index.tsx @@ -81,6 +81,7 @@ const createStyles = (theme: AppTheme) => StyleSheet.create({ }, contributionText: { color: theme.card, + flexShrink: 1, }, bottomBar: { borderTopWidth: 1, diff --git a/app/(auth)/project/_layout.tsx b/app/(auth)/project/_layout.tsx index 7f5667d..bde3b1f 100644 --- a/app/(auth)/project/_layout.tsx +++ b/app/(auth)/project/_layout.tsx @@ -1,5 +1,7 @@ import { Stack } from 'expo-router'; export default function ProjectLayout() { - return ; + return ( + + ); } diff --git a/app/login.tsx b/app/login.tsx index afcee0c..4bbecba 100644 --- a/app/login.tsx +++ b/app/login.tsx @@ -23,15 +23,11 @@ import useThemedStyles from '@/hooks/useThemedStyles'; import { firebaseAuth } from '@/utils/firebase'; const createStyles = (theme: AppTheme) => StyleSheet.create({ - mainContent: { - flexDirection: 'column', - gap: 48, - }, icon: { width: 128, height: 128, }, - page: { + logoContainer: { paddingTop: 96, }, text: { @@ -87,12 +83,14 @@ function Login() { - + StyleSheet.create({ - mainContent: { - flexDirection: 'column', - gap: 48, - }, icon: { width: 128, height: 128, }, - page: { + logoContainer: { paddingTop: 96, }, text: { @@ -183,12 +179,14 @@ function Register() { - + {children} : children; + + if (showBackButton) { + // Header is shown — let it handle the top inset + return ( + + {content} + + ); + } + + // No header — apply top safe area ourselves return ( - - {scrollable ? ( - - {children} - - ) : children} + + {content} ); } diff --git a/components/showConfirm.ts b/components/showConfirm.ts new file mode 100644 index 0000000..842598f --- /dev/null +++ b/components/showConfirm.ts @@ -0,0 +1,49 @@ +import { + Alert, + Platform, +} from 'react-native'; + +type ConfirmOptions = { + title: string; + message?: string; + confirmText?: string; + cancelText?: string; + destructive?: boolean; + onConfirm: () => void; + onCancel?: () => void; +}; + +export default function showConfirm({ + title, + message, + confirmText = 'OK', + cancelText = 'Cancel', + destructive = false, + onConfirm, + onCancel, +}: ConfirmOptions) { + if (Platform.OS === 'web') { + const text = message ? `${title}\n\n${message}` : title; + // eslint-disable-next-line no-alert + if (window.confirm(text)) { + onConfirm(); + } else { + onCancel?.(); + } + return; + } + + Alert.alert( + title, + message, + [ + { text: cancelText, style: 'cancel', onPress: onCancel }, + { + text: confirmText, + style: destructive ? 'destructive' : 'default', + onPress: onConfirm, + }, + ], + { cancelable: true, onDismiss: onCancel }, + ); +}