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)/(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)/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/[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/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() {
-
+
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/Page/index.tsx b/components/Page/index.tsx
index 91a9317..4bc8419 100644
--- a/components/Page/index.tsx
+++ b/components/Page/index.tsx
@@ -2,6 +2,7 @@ import { useLayoutEffect } from 'react';
import {
ScrollView,
StyleSheet,
+ View,
ViewStyle,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
@@ -58,19 +59,21 @@ function Page(props: Props) {
});
}, [navigation, title, theme, showBackButton, headerTitleAlign]);
+ const content = scrollable ? {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/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/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 },
+ );
+}
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 buildings1> — 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 ?? '',
},
});
};