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 7e3e302..af1086a 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 fba2600..c4c34da 100644
--- a/app/(auth)/project/[id]/map/[taskGroupId].tsx
+++ b/app/(auth)/project/[id]/map/[taskGroupId].tsx
@@ -1,15 +1,26 @@
import {
+ useCallback,
+ useEffect,
useMemo,
+ useRef,
useState,
} from 'react';
-import { useLocalSearchParams } from 'expo-router';
+import {
+ useLocalSearchParams,
+ useRouter,
+} from 'expo-router';
+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';
import TileGridMappingSession from '@/components/TileGridMappingSession';
import ValidateImageMappingSession from '@/components/ValidateImageMappingSession';
import ValidateMappingSession from '@/components/ValidateMappingSession';
+import useAuth from '@/hooks/useAuth';
import useFirebaseDatabase from '@/hooks/useFirebaseDatabase';
import { firebaseRef } from '@/utils/firebase';
import {
@@ -17,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,
@@ -28,6 +40,12 @@ function MapTaskGroup() {
id: projectId,
taskGroupId,
} = useLocalSearchParams<{id: string; taskGroupId: string;}>();
+ const router = useRouter();
+
+ const startTimestampRef = useRef(undefined);
+ const endTimestampRef = useRef(undefined);
+
+ const { user } = useAuth();
const projectQuery = useMemo(
() => firebaseRef(`v2/projects/${projectId}`),
@@ -38,6 +56,87 @@ function MapTaskGroup() {
query: projectQuery,
});
const [results, setResults] = useState({});
+ const [completed, setCompleted] = useState(false);
+ const [resultSyncStatus, setResultSyncStatus] = useState('not-started');
+
+ const userId = user?.uid;
+
+ const handleSessionComplete = useCallback(() => {
+ endTimestampRef.current = new Date().toISOString();
+ setCompleted(true);
+ }, []);
+
+ const saveResults = useCallback(async () => {
+ if (isNotDefined(userId)) {
+ return false;
+ }
+
+ setResultSyncStatus('in-progress');
+ const resultsLocationRef = firebaseRef(
+ `v2/results/${projectId}/${taskGroupId}/${userId}`,
+ );
+
+ const resultPayload = {
+ startTime: startTimestampRef.current,
+ endTime: endTimestampRef.current,
+ results,
+ // FIXME: add actual appVersion and clientType
+ appVersion: '3.0.0 (0)-dev',
+ clientType: 'mobile-android',
+ };
+
+ try {
+ await setToDatabase(resultsLocationRef, resultPayload);
+ setResultSyncStatus('successful');
+ return true;
+ } catch (err: unknown) {
+ // eslint-disable-next-line no-console
+ console.error(err);
+ setResultSyncStatus('failed');
+ return false;
+ }
+ }, [projectId, taskGroupId, userId, results]);
+
+ const handleCompleteSession = useCallback(async () => {
+ const ok = await saveResults();
+ if (ok) {
+ router.replace('/');
+ }
+ }, [saveResults, router]);
+
+ const handleContinueMapping = useCallback(async () => {
+ const ok = await saveResults();
+ if (!ok) {
+ return;
+ }
+ setResults({});
+ setCompleted(false);
+ setResultSyncStatus('not-started');
+ startTimestampRef.current = new Date().toISOString();
+ endTimestampRef.current = undefined;
+ router.replace({
+ pathname: '/project/[id]/map',
+ params: {
+ id: projectId,
+ projectInstruction: projectDetails?.projectInstruction,
+ },
+ });
+ }, [saveResults, router, projectId, projectDetails?.projectInstruction]);
+
+ const handleGoBack = useCallback(() => {
+ setCompleted(false);
+ }, []);
+
+ const handleDiscardSession = useCallback(() => {
+ setResults({});
+ setCompleted(false);
+ setResultSyncStatus('not-started');
+ router.replace('/');
+ }, [router]);
+
+ useEffect(() => {
+ startTimestampRef.current = new Date().toISOString();
+ }, []);
return (
- {projectDetails?.projectType === PROJECT_TYPE_FIND && (
-
- )}
- {projectDetails?.projectType === PROJECT_TYPE_COMPARE && (
-
- )}
- {projectDetails?.projectType === PROJECT_TYPE_VALIDATE && (
-
- )}
- {projectDetails?.projectType === PROJECT_TYPE_COMPLETENESS && (
-
- )}
- {projectDetails?.projectType === PROJECT_TYPE_VALIDATE_IMAGE && (
-
- )}
- {projectDetails?.projectType === PROJECT_TYPE_STREET && (
-
+ ) : (
+ <>
+ {projectDetails?.projectType === PROJECT_TYPE_FIND && (
+
+ )}
+ {projectDetails?.projectType === PROJECT_TYPE_COMPARE && (
+
+ )}
+ {projectDetails?.projectType === PROJECT_TYPE_VALIDATE && (
+
+ )}
+ {projectDetails?.projectType === PROJECT_TYPE_COMPLETENESS && (
+
+ )}
+ {projectDetails?.projectType === PROJECT_TYPE_VALIDATE_IMAGE && (
+
+ )}
+ {projectDetails?.projectType === PROJECT_TYPE_STREET && (
+
+ )}
+ {projectDetails?.projectType === PROJECT_TYPE_LOCATE_FEATURES && (
+
+ )}
+ >
)}
);
diff --git a/app/(auth)/project/[id]/tutorial.tsx b/app/(auth)/project/[id]/tutorial.tsx
index f301a37..959fd03 100644
--- a/app/(auth)/project/[id]/tutorial.tsx
+++ b/app/(auth)/project/[id]/tutorial.tsx
@@ -1,123 +1,190 @@
-import { useMemo } from 'react';
-import { StyleSheet } from 'react-native';
-import { Image } from 'expo-image';
+import {
+ useCallback,
+ useMemo,
+ useState,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ ActivityIndicator,
+ StyleSheet,
+ View,
+} from 'react-native';
import { useLocalSearchParams } from 'expo-router';
import { isDefined } from '@togglecorp/fujs';
-import BlockListView from '@/components/BlockListView';
import Page from '@/components/Page';
-import Text from '@/components/Text';
-import { IMAGE_SIZE_MD } from '@/constants/dimensions';
+import TutorialPager from '@/components/tutorial/TutorialPager';
+import {
+ ScenarioState,
+ TutorialStage,
+} from '@/components/tutorial/types';
import useFirebaseDatabase from '@/hooks/useFirebaseDatabase';
import { firebaseRef } from '@/utils/firebase';
+import {
+ AnyTutorialTask,
+ decompressTasks,
+ groupTasksByScreen,
+ TUTORIAL_MAX_ATTEMPTS,
+} from '@/utils/tutorial';
import {
FbProject,
FbTutorial,
+ Results,
} from '@/utils/types';
const styles = StyleSheet.create({
- blockNumber: {
- width: '100%',
- height: IMAGE_SIZE_MD,
+ loaderContainer: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
},
});
function Tutorial() {
const { id: projectId } = useLocalSearchParams<{ id: string }>();
+ const { t } = useTranslation('tutorialScreen');
const projectQuery = useMemo(() => (
firebaseRef(`v2/projects/${projectId}`)
), [projectId]);
const { data: projectDetails } = useFirebaseDatabase({ query: projectQuery });
+ const tutorialId = projectDetails?.tutorialId;
const tutorialQuery = useMemo(() => (
- isDefined(projectDetails?.tutorialId)
- ? firebaseRef(`v2/projects/${projectDetails?.tutorialId}`)
- : undefined
- ), [projectDetails?.tutorialId]);
+ isDefined(tutorialId) ? firebaseRef(`v2/projects/${tutorialId}`) : undefined
+ ), [tutorialId]);
const { data: tutorialDetails } = useFirebaseDatabase({ query: tutorialQuery });
+ const tasksQuery = useMemo(() => (
+ isDefined(tutorialId) ? firebaseRef(`v2/tasks/${tutorialId}`) : undefined
+ ), [tutorialId]);
+
+ const { data: tasksByGroup } = useFirebaseDatabase>({
+ query: tasksQuery,
+ });
+
+ const allTasks = useMemo(() => {
+ if (!tasksByGroup) {
+ return [];
+ }
+ return Object.values(tasksByGroup).flatMap((groupValue) => (
+ decompressTasks(groupValue as string | AnyTutorialTask[])
+ ));
+ }, [tasksByGroup]);
+
+ const tasksByScreen = useMemo(() => groupTasksByScreen(allTasks), [allTasks]);
+
+ const stages = useMemo(() => {
+ if (!tutorialDetails) {
+ return [];
+ }
+ const list: TutorialStage[] = [];
+ list.push({ type: 'intro', tutorial: tutorialDetails });
+ (tutorialDetails.informationPages ?? []).forEach((page) => {
+ list.push({ type: 'info', page });
+ });
+ (tutorialDetails.screens ?? []).forEach((screen, i) => {
+ list.push({
+ type: 'scenario',
+ screen,
+ screenIndex: i,
+ tasks: tasksByScreen[i + 1] ?? tasksByScreen[i] ?? [],
+ });
+ });
+ list.push({ type: 'outro', tutorial: tutorialDetails });
+ return list;
+ }, [tutorialDetails, tasksByScreen]);
+
+ const [currentIndex, setCurrentIndex] = useState(0);
+ const [scenarioResults, setScenarioResults] = useState>({});
+ const [scenarioStates, setScenarioStates] = useState>({});
+ const [attemptCounts, setAttemptCounts] = useState>({});
+
+ const handleScenarioResultsChange = useCallback((
+ screenIndex: number,
+ next: Results | ((prev: Results) => Results),
+ ) => {
+ setScenarioResults((prev) => {
+ const previousForScreen = prev[screenIndex] ?? {};
+ const resolved = typeof next === 'function' ? next(previousForScreen) : next;
+ if (resolved === previousForScreen) {
+ return prev;
+ }
+ return { ...prev, [screenIndex]: resolved };
+ });
+ }, []);
+
+ const handleScenarioSubmit = useCallback((screenIndex: number, correct: boolean) => {
+ setAttemptCounts((prev) => {
+ const nextAttempts = (prev[screenIndex] ?? 0) + 1;
+ let nextState: ScenarioState;
+ if (correct) {
+ nextState = 'correct';
+ } else if (nextAttempts >= TUTORIAL_MAX_ATTEMPTS) {
+ nextState = 'skip-unlocked';
+ } else {
+ nextState = 'wrong';
+ }
+ setScenarioStates((prevStates) => ({
+ ...prevStates,
+ [screenIndex]: nextState,
+ }));
+ return { ...prev, [screenIndex]: nextAttempts };
+ });
+ }, []);
+
+ const handleScenarioShowAnswers = useCallback((screenIndex: number) => {
+ setScenarioStates((prev) => ({ ...prev, [screenIndex]: 'answers-shown' }));
+ }, []);
+
+ const canAdvanceFrom = useCallback((index: number) => {
+ const stage = stages[index];
+ if (!stage || stage.type !== 'scenario') {
+ return true;
+ }
+ const state = scenarioStates[stage.screenIndex];
+ return state === 'correct'
+ || state === 'skip-unlocked'
+ || state === 'answers-shown';
+ }, [stages, scenarioStates]);
+
+ const isLoading = !projectDetails || !tutorialDetails;
+
return (
-
-
-
- {tutorialDetails?.name}
-
-
- {`You are looking for: ${tutorialDetails?.lookFor ?? '--'}`}
-
-
-
- {tutorialDetails?.informationPages?.map((page) => (
-
-
- {page.title}
-
- {page.blocks?.map((block) => {
- if (isDefined(block.textDescription)) {
- return (
-
- {block.textDescription}
-
- );
- }
-
- if (isDefined(block.image)) {
- return (
-
- );
- }
-
- return null;
- })}
-
- ))}
- {tutorialDetails?.screens?.map((screen, i) => (
-
-
-
- {screen.hint.title}
-
-
- {screen.hint.description}
-
-
-
-
- {screen.success.title}
-
-
- {screen.success.description}
-
-
-
-
- {screen.instructions.title}
-
-
- {screen.instructions.description}
-
-
-
- ))}
-
-
+ {isLoading ? (
+
+
+
+ ) : (
+
+ )}
);
}
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() {
-
+
-
);
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) && (
>;
results: Results;
+ onSessionComplete: () => void;
}
function CompareMappingSession(props: Props) {
@@ -65,9 +69,11 @@ function CompareMappingSession(props: Props) {
projectDetails,
results,
onResultsChange,
+ onSessionComplete,
} = props;
const [currentTaskIndex, setCurrentTaskIndex] = useState(0);
+ const completedRef = useRef(false);
const styles = useThemedStyles(createStyles);
const {
@@ -111,13 +117,16 @@ function CompareMappingSession(props: Props) {
return;
}
- onResultsChange(
- listToMap(
+ onResultsChange((prev) => {
+ if (Object.keys(prev).length > 0) {
+ return prev;
+ }
+ return listToMap(
tasks,
({ taskId }) => taskId,
() => options[0].value,
- ),
- );
+ );
+ });
}, [tasks, options, onResultsChange]);
const getNextValue = useCallback((value: number | undefined) => {
@@ -138,10 +147,13 @@ function CompareMappingSession(props: Props) {
}, [options]);
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 optionsByValue = useMemo(() => (
@@ -155,6 +167,23 @@ function CompareMappingSession(props: Props) {
setCurrentTaskIndex(itemIndex);
}, [pageWidth, compressedTasks.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]);
+
// FIXME: Discuss with Ankit on how to better define this
const tileWidth = Math.min(pageWidth - 20, pageHeight / 2 - 120);
@@ -177,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;
@@ -218,6 +247,7 @@ function CompareMappingSession(props: Props) {
snapToOffsets={compressedTasks.map((_, i) => i * pageWidth)}
viewabilityConfig={VIEWABILITY_CONFIG}
onScroll={handleScroll}
+ onMomentumScrollEnd={handleMomentumScrollEnd}
/>
{latitude && (
= {
'egg-outline': EggIcon,
'ellipse-outline': CircleIcon,
'flag-outline': FlagIcon,
- 'general-tap': CursorClickIcon,
+ // FIXME: these icons should be updated
+ 'general-tap': HandTapIcon,
'hand-left-outline': HandIcon,
'hand-right-outline': HandIcon,
'happy-outline': SmileyIcon,
@@ -95,11 +96,12 @@ const iconMap: Record = {
'shapes-outline': ShapesIcon,
'square-outline': SquareIcon,
'star-outline': StarIcon,
- 'swipe-left': ArrowLeftIcon,
- tap: CursorClickIcon,
- 'tap-1': CursorClickIcon,
- 'tap-2': CursorClickIcon,
- 'tap-3': CursorClickIcon,
+ 'swipe-left': HandSwipeLeftIcon,
+ // FIXME: these icons should be updated
+ tap: HandTapIcon,
+ 'tap-1': HandTapIcon,
+ 'tap-2': HandTapIcon,
+ 'tap-3': HandTapIcon,
'thumbs-down-outline': ThumbsDownIcon,
'thumbs-up-outline': ThumbsUpIcon,
'triangle-outline': TriangleIcon,
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/SessionOutro.tsx b/components/SessionOutro.tsx
new file mode 100644
index 0000000..11a4869
--- /dev/null
+++ b/components/SessionOutro.tsx
@@ -0,0 +1,169 @@
+import { useTranslation } from 'react-i18next';
+import {
+ ActivityIndicator,
+ StyleSheet,
+ View,
+} from 'react-native';
+
+import BlockListView from '@/components/BlockListView';
+import Button from '@/components/Button';
+import Icon from '@/components/Icon';
+import InlineListView from '@/components/InlineListView';
+import Text from '@/components/Text';
+import { SPACING_MD } from '@/constants/dimensions';
+import useTheme from '@/hooks/useTheme';
+
+export type ResultSyncStatus = 'not-started' | 'in-progress' | 'failed' | 'successful';
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ padding: SPACING_MD,
+ justifyContent: 'center',
+ },
+});
+
+interface Props {
+ resultSyncStatus: ResultSyncStatus;
+ onContinueMapping: () => void;
+ onCompleteSession: () => void;
+ onGoBack: () => void;
+ onDiscardSession: () => void;
+}
+
+function SessionOutro(props: Props) {
+ const {
+ resultSyncStatus,
+ onContinueMapping,
+ onCompleteSession,
+ onGoBack,
+ onDiscardSession,
+ } = props;
+ const { t } = useTranslation('mappingSession');
+ const theme = useTheme();
+
+ const inProgress = resultSyncStatus === 'in-progress';
+ const failed = resultSyncStatus === 'failed';
+ const succeeded = resultSyncStatus === 'successful';
+
+ return (
+
+
+
+ {t('sessionCompleteTitle')}
+
+
+ {t('sessionCompleteMessage')}
+
+
+ {inProgress && (
+
+
+
+
+ {t('savingProgress')}
+
+
+
+ )}
+ {failed && (
+
+
+
+ {t('saveFailed')}
+
+
+ )}
+ {succeeded && (
+
+
+
+ {t('saveSucceeded')}
+
+
+ )}
+
+
+
+ {t('continueMappingHelp')}
+
+
+
+
+
+
+ {t('completeSessionHelp')}
+
+
+
+
+
+
+ {t('goBackHelp')}
+
+
+
+
+
+
+ {t('discardSessionHelp')}
+
+
+
+ );
+}
+
+export default SessionOutro;
diff --git a/components/TileGridMappingSession.tsx b/components/TileGridMappingSession.tsx
index a6422e9..35d65b5 100644
--- a/components/TileGridMappingSession.tsx
+++ b/components/TileGridMappingSession.tsx
@@ -4,10 +4,13 @@ import {
useCallback,
useEffect,
useMemo,
+ useRef,
useState,
} from 'react';
import {
FlatList,
+ type NativeScrollEvent,
+ type NativeSyntheticEvent,
StyleSheet,
useWindowDimensions,
View,
@@ -53,6 +56,7 @@ interface Props {
projectDetails: FindProject | CompletenessProject;
onResultsChange: Dispatch>;
results: Results;
+ onSessionComplete: () => void;
}
function TileGridMappingSession(props: Props) {
@@ -61,9 +65,11 @@ function TileGridMappingSession(props: Props) {
projectDetails,
results,
onResultsChange,
+ onSessionComplete,
} = props;
const [currentTaskIndex, setCurrentTaskIndex] = useState(0);
+ const completedRef = useRef(false);
const styles = useThemedStyles(createStyles);
@@ -123,13 +129,16 @@ function TileGridMappingSession(props: Props) {
return;
}
- onResultsChange(
- listToMap(
+ onResultsChange((prev) => {
+ if (Object.keys(prev).length > 0) {
+ return prev;
+ }
+ return listToMap(
tasks,
({ taskId }) => taskId,
() => options[0].value,
- ),
- );
+ );
+ });
}, [tasks, options, onResultsChange]);
const groupedTasks = useMemo(() => {
@@ -178,11 +187,31 @@ function TileGridMappingSession(props: Props) {
setCurrentTaskIndex(itemIndex);
}, [pageWidth, groupedTasks.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 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(() => {
@@ -206,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;
@@ -239,6 +268,7 @@ function TileGridMappingSession(props: Props) {
snapToOffsets={groupedTasks.map((_, i) => i * pageWidth)}
viewabilityConfig={VIEWABILITY_CONFIG}
onScroll={handleScroll}
+ onMomentumScrollEnd={handleMomentumScrollEnd}
scrollEventThrottle={16}
windowSize={3}
initialNumToRender={2}
diff --git a/components/ValidateImageMappingSession.tsx b/components/ValidateImageMappingSession.tsx
index e9682ee..c9f7d18 100644
--- a/components/ValidateImageMappingSession.tsx
+++ b/components/ValidateImageMappingSession.tsx
@@ -60,6 +60,7 @@ interface Props {
projectDetails: ValidateImageProject;
onResultsChange: Dispatch>;
results: Results;
+ onSessionComplete: () => void;
}
function ValidateImageMappingSession(props: Props) {
@@ -68,10 +69,12 @@ function ValidateImageMappingSession(props: Props) {
projectDetails,
onResultsChange,
results,
+ onSessionComplete,
} = props;
const styles = useThemedStyles(createStyles);
const [currentTaskIndex, setCurrentTaskIndex] = useState(0);
+ const completedRef = useRef(false);
const [imagesLoading, setImagesLoading] = useState>({});
const taskQuery = useMemo(() => (
@@ -125,8 +128,11 @@ function ValidateImageMappingSession(props: Props) {
animated: true,
});
}, 0);
+ } else if (!completedRef.current) {
+ completedRef.current = true;
+ onSessionComplete();
}
- }, [currentTask, onResultsChange, maxTasks, currentTaskIndex]);
+ }, [currentTask, onResultsChange, maxTasks, currentTaskIndex, onSessionComplete]);
const selectedValue = isDefined(currentTask)
? results[currentTask.taskId]
diff --git a/components/ValidateMappingSession.tsx b/components/ValidateMappingSession.tsx
index fb5c2bd..2fcb13f 100644
--- a/components/ValidateMappingSession.tsx
+++ b/components/ValidateMappingSession.tsx
@@ -69,6 +69,7 @@ interface Props {
projectDetails: ValidateProject;
onResultsChange: Dispatch>;
results: Results;
+ onSessionComplete: () => void;
}
function ValidateMappingSession(props: Props) {
@@ -77,9 +78,11 @@ function ValidateMappingSession(props: Props) {
projectDetails,
results,
onResultsChange,
+ onSessionComplete,
} = props;
const [currentTaskIndex, setCurrentTaskIndex] = useState(0);
+ const completedRef = useRef(false);
const taskQuery = useMemo(() => (
firebaseRef(`v2/tasks/${projectDetails.projectId}/${taskGroupId}`)
@@ -131,8 +134,11 @@ function ValidateMappingSession(props: Props) {
animated: true,
});
}, 0);
+ } else if (!completedRef.current) {
+ completedRef.current = true;
+ onSessionComplete();
}
- }, [currentTask, onResultsChange, maxTasks, currentTaskIndex]);
+ }, [currentTask, onResultsChange, maxTasks, currentTaskIndex, onSessionComplete]);
const selectedValue = isDefined(currentTask)
? results[currentTask.taskId]
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/CompareInstructions.tsx b/components/tutorial/CompareInstructions.tsx
new file mode 100644
index 0000000..43e2479
--- /dev/null
+++ b/components/tutorial/CompareInstructions.tsx
@@ -0,0 +1,129 @@
+import {
+ Trans,
+ useTranslation,
+} from 'react-i18next';
+
+import BlockListView from '@/components/BlockListView';
+import Icon from '@/components/Icon';
+import Text from '@/components/Text';
+import useTheme from '@/hooks/useTheme';
+
+import InstructionRow from './InstructionRow';
+import TapBadgeIcon from './TapBadgeIcon';
+
+const boldStyle = { fontWeight: 'bold' } as const;
+
+function CompareInstructions() {
+ const { t } = useTranslation('instructionsScreen');
+ const theme = useTheme();
+
+ return (
+
+
+ {t('compareYourTask')}
+
+
+
+ You're looking for
+ changes in buildings
+ . This acts as a clear indicator for a change in population size.
+
+
+
+
+ {t('comparePerformTask')}
+
+
+ }
+ description={(
+
+ If there are no changes, simply
+ swipe
+ to the next photos.
+
+ )}
+ />
+
+ )}
+ description={(
+
+ If you see a change in buildings,
+ tap once
+ and the tile turns green.
+
+ )}
+ />
+
+ )}
+ description={(
+
+ Unsure?
+ Tap twice
+ and the tile turns yellow.
+
+ )}
+ />
+
+ )}
+ description={(
+
+ Imagery issue, like clouds covering the view?
+ Tap three times
+ and the tile turns red.
+
+ )}
+ />
+ }
+ description={(
+
+ Tap and hold
+ to hide icons and overlay.
+
+ )}
+ />
+
+
+ {t('compareHoldZoom')}
+
+
+
+ {t('compareHint')}
+
+
+
+ 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
+
+ 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.
+
+
+
+ );
+}
+
+export default CompareInstructions;
diff --git a/components/tutorial/CompareTutorialSession.tsx b/components/tutorial/CompareTutorialSession.tsx
new file mode 100644
index 0000000..27125a4
--- /dev/null
+++ b/components/tutorial/CompareTutorialSession.tsx
@@ -0,0 +1,144 @@
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+} from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ StyleSheet,
+ useWindowDimensions,
+ View,
+} from 'react-native';
+import {
+ isDefined,
+ isNotDefined,
+ listToMap,
+} from '@togglecorp/fujs';
+
+import ImageTile from '@/components/ImageTile';
+import Text from '@/components/Text';
+import { TutorialSessionProps } from '@/components/tutorial/types';
+import { SPACING_3XS } from '@/constants/dimensions';
+import {
+ ResultOption,
+ Results,
+} from '@/utils/types';
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: SPACING_3XS,
+ },
+ pair: {
+ alignItems: 'center',
+ gap: SPACING_3XS,
+ },
+});
+
+const OPTIONS: ResultOption[] = [
+ { value: 0, label: 'No', color: 'transparent' },
+ { value: 1, label: 'Yes', color: 'green' },
+ { value: 2, label: 'Maybe', color: 'yellow' },
+ { value: 3, label: 'Bad Imagery', color: 'red' },
+];
+
+const optionsByValue = listToMap(OPTIONS, ({ value }) => value);
+
+function getNextValue(value: number | undefined) {
+ if (isNotDefined(value)) {
+ return OPTIONS[0].value;
+ }
+ 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;
+}
+
+function CompareTutorialSession(props: TutorialSessionProps) {
+ const {
+ tasks,
+ results,
+ onResultsChange,
+ disabled,
+ } = props;
+
+ const { t } = useTranslation('tutorialScreen');
+ const { width: pageWidth, height: pageHeight } = useWindowDimensions();
+
+ useEffect(() => {
+ if (tasks.length === 0) {
+ return;
+ }
+ onResultsChange((prev) => {
+ const missing = tasks.filter((task) => !(task.taskId in prev));
+ if (missing.length === 0) {
+ return prev;
+ }
+ const next: Results = { ...prev };
+ missing.forEach((task) => {
+ next[task.taskId] = OPTIONS[0].value;
+ });
+ return next;
+ });
+ }, [tasks, onResultsChange]);
+
+ const tileWidth = useMemo(() => (
+ Math.max(100, Math.min(pageWidth - 80, pageHeight / 2 - 200))
+ ), [pageWidth, pageHeight]);
+
+ const handleTilePress = useCallback((taskId: string) => {
+ if (disabled) {
+ return;
+ }
+ onResultsChange((prev) => {
+ const prevValue = prev[taskId];
+ return {
+ ...prev,
+ [taskId]: getNextValue(typeof prevValue === 'number' ? prevValue : undefined),
+ };
+ });
+ }, [disabled, onResultsChange]);
+
+ return (
+
+ {tasks.map((task) => {
+ if (!('url' in task) || !('urlB' in task) || !task.url || !task.urlB) {
+ return null;
+ }
+ const result = results[task.taskId];
+ const selectedOption = typeof result === 'number'
+ ? optionsByValue[result]
+ : undefined;
+
+ return (
+
+ {t('compareBefore')}
+
+ {t('compareAfter')}
+
+
+ );
+ })}
+
+ );
+}
+
+export default CompareTutorialSession;
diff --git a/components/tutorial/InstructionRow.tsx b/components/tutorial/InstructionRow.tsx
new file mode 100644
index 0000000..47b1f17
--- /dev/null
+++ b/components/tutorial/InstructionRow.tsx
@@ -0,0 +1,54 @@
+import {
+ StyleSheet,
+ View,
+} from 'react-native';
+
+import BlockListView from '@/components/BlockListView';
+import Text from '@/components/Text';
+import { SPACING_SM } from '@/constants/dimensions';
+
+const styles = StyleSheet.create({
+ row: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ gap: SPACING_SM,
+ },
+ iconSlot: {
+ width: 50,
+ alignItems: 'center',
+ justifyContent: 'flex-start',
+ },
+ textColumn: {
+ flex: 1,
+ },
+});
+
+interface Props {
+ icon: React.ReactNode;
+ title?: string;
+ description: React.ReactNode;
+}
+
+function InstructionRow(props: Props) {
+ const { icon, title, description } = props;
+
+ return (
+
+
+ {icon}
+
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {description}
+
+
+
+ );
+}
+
+export default InstructionRow;
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/ScenarioFeedback.tsx b/components/tutorial/ScenarioFeedback.tsx
new file mode 100644
index 0000000..17abf78
--- /dev/null
+++ b/components/tutorial/ScenarioFeedback.tsx
@@ -0,0 +1,106 @@
+import {
+ StyleSheet,
+ View,
+} from 'react-native';
+
+import Icon, { type IconName } from '@/components/Icon';
+import Text from '@/components/Text';
+import {
+ SPACING_2XS,
+ SPACING_4XS,
+ SPACING_XS,
+} from '@/constants/dimensions';
+import { AppTheme } from '@/constants/theme';
+import useThemedStyles from '@/hooks/useThemedStyles';
+import {
+ FbScreen,
+ FbScreenBlock,
+} from '@/utils/types';
+
+import { ScenarioState } from './types';
+
+type Tone = 'instructions' | 'hint' | 'success';
+
+const createStyles = (theme: AppTheme, { tone }: { tone: Tone }) => {
+ const colorMap: Record = {
+ instructions: theme.info,
+ hint: theme.warning,
+ success: theme.success,
+ };
+
+ return StyleSheet.create({
+ card: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ gap: SPACING_2XS,
+ borderRadius: 12,
+ padding: SPACING_XS,
+ backgroundColor: colorMap[tone],
+ },
+ iconWrapper: {
+ paddingTop: 2,
+ },
+ body: {
+ flex: 1,
+ gap: SPACING_4XS,
+ },
+ title: {
+ color: theme.textOnPrimary,
+ fontWeight: 'bold',
+ },
+ description: {
+ color: theme.textOnPrimary,
+ },
+ });
+};
+
+interface CardProps {
+ tone: Tone;
+ block: FbScreenBlock;
+ descriptionSuffix?: string;
+}
+
+function FeedbackCard(props: CardProps) {
+ const { tone, block, descriptionSuffix } = props;
+ const styles = useThemedStyles(createStyles, { tone });
+ const description = descriptionSuffix
+ ? `${block.description} ${descriptionSuffix}`
+ : block.description;
+
+ return (
+
+
+
+
+
+ {block.title}
+ {description}
+
+
+ );
+}
+
+interface Props {
+ screen: FbScreen;
+ state: ScenarioState;
+}
+
+function ScenarioFeedback(props: Props) {
+ const { screen, state } = props;
+
+ if (state === 'correct') {
+ return ;
+ }
+
+ if (state === 'answers-shown') {
+ return ;
+ }
+
+ return ;
+}
+
+export default ScenarioFeedback;
diff --git a/components/tutorial/StageIndicator.tsx b/components/tutorial/StageIndicator.tsx
new file mode 100644
index 0000000..8986f4b
--- /dev/null
+++ b/components/tutorial/StageIndicator.tsx
@@ -0,0 +1,50 @@
+import {
+ StyleSheet,
+ View,
+} from 'react-native';
+
+import { SPACING_3XS } from '@/constants/dimensions';
+import { AppTheme } from '@/constants/theme';
+import useThemedStyles from '@/hooks/useThemedStyles';
+
+const createStyles = (theme: AppTheme) => StyleSheet.create({
+ container: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ gap: SPACING_3XS,
+ },
+ dot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ backgroundColor: theme.backgroundTrack,
+ },
+ activeDot: {
+ backgroundColor: theme.textOnBrand,
+ width: 16,
+ },
+});
+
+interface Props {
+ total: number;
+ currentIndex: number;
+}
+
+function StageIndicator(props: Props) {
+ const { total, currentIndex } = props;
+ const styles = useThemedStyles(createStyles);
+
+ return (
+
+ {Array.from({ length: total }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+export default StageIndicator;
diff --git a/components/tutorial/TapBadgeIcon.tsx b/components/tutorial/TapBadgeIcon.tsx
new file mode 100644
index 0000000..c28f5d9
--- /dev/null
+++ b/components/tutorial/TapBadgeIcon.tsx
@@ -0,0 +1,65 @@
+import {
+ StyleSheet,
+ View,
+} from 'react-native';
+
+import Icon, { type IconName } from '@/components/Icon';
+import Text from '@/components/Text';
+
+const ICON_SIZE = 40;
+const BADGE_SIZE = 18;
+
+const styles = StyleSheet.create({
+ wrapper: {
+ width: ICON_SIZE,
+ height: ICON_SIZE,
+ position: 'relative',
+ },
+ badge: {
+ position: 'absolute',
+ top: -2,
+ right: -4,
+ width: BADGE_SIZE,
+ height: BADGE_SIZE,
+ borderRadius: BADGE_SIZE / 2,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ badgeText: {
+ fontSize: 11,
+ fontWeight: 'bold',
+ color: '#FFFFFF',
+ lineHeight: BADGE_SIZE,
+ },
+});
+
+interface Props {
+ iconName?: IconName;
+ badgeNumber?: number;
+ badgeColor?: string;
+}
+
+function TapBadgeIcon(props: Props) {
+ const {
+ iconName = 'tap',
+ badgeNumber,
+ badgeColor,
+ } = props;
+
+ return (
+
+
+ {badgeNumber && badgeColor && (
+
+ {String(badgeNumber)}
+
+ )}
+
+ );
+}
+
+export default TapBadgeIcon;
diff --git a/components/tutorial/TileGridInstructions.tsx b/components/tutorial/TileGridInstructions.tsx
new file mode 100644
index 0000000..9f0b6e4
--- /dev/null
+++ b/components/tutorial/TileGridInstructions.tsx
@@ -0,0 +1,106 @@
+import {
+ Trans,
+ useTranslation,
+} from 'react-i18next';
+
+import BlockListView from '@/components/BlockListView';
+import Icon from '@/components/Icon';
+import Text from '@/components/Text';
+import useTheme from '@/hooks/useTheme';
+
+import InstructionRow from './InstructionRow';
+import TapBadgeIcon from './TapBadgeIcon';
+
+const boldStyle = { fontWeight: 'bold' } as const;
+
+function TileGridInstructions() {
+ const { t } = useTranslation('instructionsScreen');
+ const theme = useTheme();
+
+ return (
+
+
+ {t('tileGridIntro')}
+
+
+ }
+ description={(
+
+ If there's nothing relevant in the images, simply
+ swipe
+ to the next screen.
+
+ )}
+ />
+
+ )}
+ description={(
+
+ If you see something in one of the images,
+ tap once
+ and the tile turns green.
+
+ )}
+ />
+
+ )}
+ description={(
+
+ Not sure about what you see?
+ Tap twice
+ and the tile turns yellow.
+
+ )}
+ />
+
+ )}
+ description={(
+
+ If there's an issue with the imagery,
+ tap three times
+ and the tile turns red.
+
+ )}
+ />
+ }
+ description={(
+
+ Tap again
+ to return the tile to its original state.
+
+ )}
+ />
+ }
+ description={(
+
+ Tap and hold
+ to hide icons and overlay.
+
+ )}
+ />
+
+ );
+}
+
+export default TileGridInstructions;
diff --git a/components/tutorial/TileGridTutorialSession.tsx b/components/tutorial/TileGridTutorialSession.tsx
new file mode 100644
index 0000000..fa7d94c
--- /dev/null
+++ b/components/tutorial/TileGridTutorialSession.tsx
@@ -0,0 +1,167 @@
+import {
+ useCallback,
+ useEffect,
+ useMemo,
+} from 'react';
+import {
+ StyleSheet,
+ useWindowDimensions,
+ View,
+} from 'react-native';
+import {
+ compareNumber,
+ isDefined,
+ isNotDefined,
+ listToGroupList,
+ listToMap,
+ mapToList,
+} from '@togglecorp/fujs';
+
+import ImageTile from '@/components/ImageTile';
+import { TutorialSessionProps } from '@/components/tutorial/types';
+import { TileTutorialTask } from '@/utils/tutorial';
+import {
+ PROJECT_TYPE_COMPLETENESS,
+ ResultOption,
+ Results,
+} from '@/utils/types';
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ column: {},
+ row: {
+ flexDirection: 'row',
+ },
+});
+
+const OPTIONS: ResultOption[] = [
+ { value: 0, label: 'No', color: 'transparent' },
+ { value: 1, label: 'Yes', color: 'green' },
+ { value: 2, label: 'Maybe', color: 'yellow' },
+ { value: 3, label: 'Bad Imagery', color: 'red' },
+];
+
+const optionsByValue = listToMap(OPTIONS, ({ value }) => value);
+
+function getNextValue(value: number | undefined) {
+ if (isNotDefined(value)) {
+ return OPTIONS[0].value;
+ }
+ 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;
+}
+
+function TileGridTutorialSession(props: TutorialSessionProps) {
+ const {
+ tutorial,
+ tasks,
+ results,
+ onResultsChange,
+ disabled,
+ } = props;
+
+ const { width: pageWidth, height: pageHeight } = useWindowDimensions();
+
+ useEffect(() => {
+ if (tasks.length === 0) {
+ return;
+ }
+ onResultsChange((prev) => {
+ const missing = tasks.filter((t) => !(t.taskId in prev));
+ if (missing.length === 0) {
+ return prev;
+ }
+ const next: Results = { ...prev };
+ missing.forEach((task) => {
+ next[task.taskId] = OPTIONS[0].value;
+ });
+ return next;
+ });
+ }, [tasks, onResultsChange]);
+
+ const groupedColumns = useMemo(() => {
+ const tileTasks = tasks.filter((t): t is TileTutorialTask => (
+ 'taskX' in t && 'taskY' in t
+ && typeof t.taskX === 'number' && typeof t.taskY === 'number'
+ )).sort((a, b) => (
+ compareNumber(a.taskX, b.taskX) || compareNumber(a.taskY, b.taskY)
+ ));
+
+ return mapToList(
+ listToGroupList(tileTasks, (t) => t.taskX),
+ (rows, key) => ({
+ taskX: key,
+ rows: [...rows].sort((a, b) => compareNumber(a.taskY, b.taskY)),
+ }),
+ );
+ }, [tasks]);
+
+ const numCols = groupedColumns.length || 1;
+ const numRows = groupedColumns[0]?.rows.length || 1;
+
+ const tileWidth = useMemo(() => {
+ const horizontalBudget = (pageWidth - 24) / numCols;
+ const verticalBudget = (pageHeight * 0.65) / numRows;
+ return Math.max(80, Math.min(horizontalBudget, verticalBudget));
+ }, [pageWidth, pageHeight, numCols, numRows]);
+
+ const handleTilePress = useCallback((taskId: string) => {
+ if (disabled) {
+ return;
+ }
+ onResultsChange((prev) => {
+ const prevValue = prev[taskId];
+ return {
+ ...prev,
+ [taskId]: getNextValue(typeof prevValue === 'number' ? prevValue : undefined),
+ };
+ });
+ }, [disabled, onResultsChange]);
+
+ return (
+
+
+ {groupedColumns.map((column) => (
+
+ {column.rows.map((task) => {
+ const result = results[task.taskId];
+ const selectedOption = typeof result === 'number'
+ ? optionsByValue[result]
+ : undefined;
+
+ if (!task.url) {
+ return null;
+ }
+
+ return (
+
+ );
+ })}
+
+ ))}
+
+
+ );
+}
+
+export default TileGridTutorialSession;
diff --git a/components/tutorial/TutorialInformationPage.tsx b/components/tutorial/TutorialInformationPage.tsx
new file mode 100644
index 0000000..599e5f4
--- /dev/null
+++ b/components/tutorial/TutorialInformationPage.tsx
@@ -0,0 +1,69 @@
+import {
+ ScrollView,
+ StyleSheet,
+} from 'react-native';
+import { Image } from 'expo-image';
+import { isDefined } from '@togglecorp/fujs';
+
+import BlockListView from '@/components/BlockListView';
+import Text from '@/components/Text';
+import {
+ IMAGE_SIZE_MD,
+ SPACING_2XS,
+ SPACING_SM,
+} from '@/constants/dimensions';
+import { FbInformationPage } from '@/utils/types';
+
+const styles = StyleSheet.create({
+ scroll: {
+ flex: 1,
+ },
+ content: {
+ padding: SPACING_SM,
+ gap: SPACING_2XS,
+ },
+ image: {
+ width: '100%',
+ height: IMAGE_SIZE_MD,
+ borderRadius: 8,
+ },
+});
+
+interface Props {
+ page: FbInformationPage;
+}
+
+function TutorialInformationPage(props: Props) {
+ const { page } = props;
+
+ return (
+
+
+ {page.title}
+
+
+ {page.blocks?.map((block) => {
+ if (isDefined(block.textDescription)) {
+ return (
+
+ {block.textDescription}
+
+ );
+ }
+ if (isDefined(block.image)) {
+ return (
+
+ );
+ }
+ return null;
+ })}
+
+
+ );
+}
+
+export default TutorialInformationPage;
diff --git a/components/tutorial/TutorialIntroPage.tsx b/components/tutorial/TutorialIntroPage.tsx
new file mode 100644
index 0000000..ef22445
--- /dev/null
+++ b/components/tutorial/TutorialIntroPage.tsx
@@ -0,0 +1,117 @@
+import { useTranslation } from 'react-i18next';
+import {
+ ScrollView,
+ StyleSheet,
+} from 'react-native';
+import { Image } from 'expo-image';
+import {
+ isDefined,
+ isTruthyString,
+} from '@togglecorp/fujs';
+
+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 {
+ IMAGE_SIZE_MD,
+ SPACING_SM,
+ 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';
+
+const styles = StyleSheet.create({
+ scroll: {
+ flex: 1,
+ },
+ content: {
+ padding: SPACING_SM,
+ gap: SPACING_XS,
+ },
+ image: {
+ width: '100%',
+ height: IMAGE_SIZE_MD,
+ borderRadius: 12,
+ },
+});
+
+interface Props {
+ tutorial: FbTutorial;
+ projectCustomOptions?: FbObjCustomOption[];
+}
+
+function TutorialIntroPage(props: Props) {
+ const { tutorial, projectCustomOptions } = props;
+ const { t } = useTranslation(['instructionsScreen', 'tutorialScreen']);
+
+ const instructionLine = (() => {
+ if (
+ 'instruction' in tutorial
+ && typeof tutorial.instruction === 'string'
+ && isTruthyString(tutorial.instruction)
+ ) {
+ return tutorial.instruction;
+ }
+ if (isTruthyString(tutorial.lookFor)) {
+ return t('youAreLookingFor', { lookFor: tutorial.lookFor });
+ }
+ return undefined;
+ })();
+
+ let typeInstructions: React.ReactNode = null;
+ if (
+ tutorial.projectType === PROJECT_TYPE_FIND
+ || tutorial.projectType === PROJECT_TYPE_COMPLETENESS
+ ) {
+ typeInstructions = ;
+ } else if (
+ tutorial.projectType === PROJECT_TYPE_VALIDATE
+ || tutorial.projectType === PROJECT_TYPE_VALIDATE_IMAGE
+ ) {
+ typeInstructions = (
+
+ );
+ } else if (tutorial.projectType === PROJECT_TYPE_COMPARE) {
+ typeInstructions = ;
+ } else if (tutorial.projectType === PROJECT_TYPE_LOCATE_FEATURES) {
+ typeInstructions = ;
+ }
+
+ return (
+
+
+
+ {tutorial.name}
+
+ {isDefined(instructionLine) && (
+
+ {instructionLine}
+
+ )}
+
+ {isDefined(tutorial.exampleImage1) && (
+
+ )}
+ {typeInstructions}
+
+ {t('tutorialScreen:swipeThroughIntro')}
+
+
+ );
+}
+
+export default TutorialIntroPage;
diff --git a/components/tutorial/TutorialOutroPage.tsx b/components/tutorial/TutorialOutroPage.tsx
new file mode 100644
index 0000000..2fede8b
--- /dev/null
+++ b/components/tutorial/TutorialOutroPage.tsx
@@ -0,0 +1,57 @@
+import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import { StyleSheet } from 'react-native';
+import { useRouter } from 'expo-router';
+
+import BlockListView from '@/components/BlockListView';
+import Button from '@/components/Button';
+import Text from '@/components/Text';
+import { SPACING_MD } from '@/constants/dimensions';
+import { FbTutorial } from '@/utils/types';
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ padding: SPACING_MD,
+ justifyContent: 'center',
+ gap: SPACING_MD,
+ },
+});
+
+interface Props {
+ tutorial: FbTutorial;
+ projectId: string;
+}
+
+function TutorialOutroPage(props: Props) {
+ const { tutorial, projectId } = props;
+ const router = useRouter();
+ const { t } = useTranslation('tutorialScreen');
+
+ const handleStartMapping = useCallback(() => {
+ router.replace({
+ pathname: '/project/[id]',
+ params: { id: projectId },
+ });
+ }, [router, projectId]);
+
+ return (
+
+
+ {t('readyToMap', { name: tutorial.name })}
+
+
+ {t('outroMessage')}
+
+
+
+ );
+}
+
+export default TutorialOutroPage;
diff --git a/components/tutorial/TutorialPager.tsx b/components/tutorial/TutorialPager.tsx
new file mode 100644
index 0000000..c2a104a
--- /dev/null
+++ b/components/tutorial/TutorialPager.tsx
@@ -0,0 +1,190 @@
+import {
+ useCallback,
+ useEffect,
+ useRef,
+} from 'react';
+import {
+ FlatList,
+ ListRenderItem,
+ NativeScrollEvent,
+ NativeSyntheticEvent,
+ StyleSheet,
+ useWindowDimensions,
+ View,
+} from 'react-native';
+
+import {
+ SPACING_2XS,
+ SPACING_3XS,
+ SPACING_XS,
+} from '@/constants/dimensions';
+import { FbObjCustomOption, FbTutorial, Results } from '@/utils/types';
+
+import StageIndicator from './StageIndicator';
+import TutorialInformationPage from './TutorialInformationPage';
+import TutorialIntroPage from './TutorialIntroPage';
+import TutorialOutroPage from './TutorialOutroPage';
+import TutorialScenarioPage from './TutorialScenarioPage';
+import {
+ ScenarioState,
+ TutorialStage,
+} from './types';
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ list: {
+ flex: 1,
+ },
+ page: {
+ flex: 1,
+ },
+ footer: {
+ paddingHorizontal: SPACING_XS,
+ paddingBottom: SPACING_2XS,
+ paddingTop: SPACING_3XS,
+ },
+});
+
+interface Props {
+ projectId: string;
+ tutorial: FbTutorial;
+ stages: TutorialStage[];
+ currentIndex: number;
+ onIndexChange: (index: number) => void;
+ canAdvanceFrom: (index: number) => boolean;
+ scenarioResults: Record;
+ onScenarioResultsChange: (
+ screenIndex: number,
+ 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) {
+ const {
+ projectId,
+ tutorial,
+ stages,
+ currentIndex,
+ onIndexChange,
+ canAdvanceFrom,
+ scenarioResults,
+ onScenarioResultsChange,
+ scenarioStates,
+ attemptCounts,
+ onScenarioSubmit,
+ onScenarioShowAnswers,
+ projectCustomOptions,
+ } = props;
+
+ const { width: pageWidth } = useWindowDimensions();
+ const listRef = useRef>(null);
+
+ useEffect(() => {
+ listRef.current?.scrollToIndex({ index: currentIndex, animated: true });
+ }, [currentIndex]);
+
+ const handleMomentumEnd = useCallback((event: NativeSyntheticEvent) => {
+ const newIndex = Math.round(event.nativeEvent.contentOffset.x / pageWidth);
+ if (newIndex !== currentIndex) {
+ onIndexChange(newIndex);
+ }
+ }, [pageWidth, currentIndex, onIndexChange]);
+
+ const renderStage = useCallback>(({ item }) => {
+ let content: React.ReactNode = null;
+
+ if (item.type === 'intro') {
+ content = (
+
+ );
+ } else if (item.type === 'info') {
+ content = ;
+ } else if (item.type === 'outro') {
+ content = (
+
+ );
+ } else if (item.type === 'scenario') {
+ const { screenIndex } = item;
+ const state = scenarioStates[screenIndex] ?? 'unanswered';
+ const attempts = attemptCounts[screenIndex] ?? 0;
+ const results = scenarioResults[screenIndex] ?? {};
+
+ content = (
+
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+ }, [
+ pageWidth,
+ projectId,
+ tutorial,
+ scenarioStates,
+ attemptCounts,
+ scenarioResults,
+ onScenarioResultsChange,
+ onScenarioSubmit,
+ onScenarioShowAnswers,
+ projectCustomOptions,
+ ]);
+
+ const scrollEnabled = canAdvanceFrom(currentIndex);
+
+ return (
+
+ `stage-${i}`}
+ renderItem={renderStage}
+ horizontal
+ pagingEnabled
+ showsHorizontalScrollIndicator={false}
+ scrollEnabled={scrollEnabled}
+ onMomentumScrollEnd={handleMomentumEnd}
+ getItemLayout={(_, index) => ({
+ length: pageWidth,
+ offset: pageWidth * index,
+ index,
+ })}
+ initialScrollIndex={currentIndex}
+ extraData={`${currentIndex}-${scrollEnabled}`}
+ />
+
+
+
+
+ );
+}
+
+export default TutorialPager;
diff --git a/components/tutorial/TutorialScenarioPage.tsx b/components/tutorial/TutorialScenarioPage.tsx
new file mode 100644
index 0000000..3e702c8
--- /dev/null
+++ b/components/tutorial/TutorialScenarioPage.tsx
@@ -0,0 +1,202 @@
+import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ StyleSheet,
+ View,
+} from 'react-native';
+
+import BlockListView from '@/components/BlockListView';
+import Button from '@/components/Button';
+import { showAlert } from '@/components/Toast';
+import {
+ SPACING_2XS,
+ SPACING_XS,
+} from '@/constants/dimensions';
+import {
+ AnyTutorialTask,
+ getReferenceResults,
+ isScenarioCorrect,
+ 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';
+import UnsupportedTutorialSession from './UnsupportedTutorialSession';
+import ValidateTutorialSession from './ValidateTutorialSession';
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ padding: SPACING_XS,
+ gap: SPACING_2XS,
+ },
+ sessionSlot: {
+ flex: 1,
+ },
+});
+
+interface Props {
+ tutorial: FbTutorial;
+ screen: FbScreen;
+ screenIndex: number;
+ tasks: AnyTutorialTask[];
+ results: Results;
+ onScenarioResultsChange: (
+ screenIndex: number,
+ next: Results | ((prev: Results) => Results),
+ ) => void;
+ state: ScenarioState;
+ attempts: number;
+ onScenarioSubmit: (screenIndex: number, correct: boolean) => void;
+ onScenarioShowAnswers: (screenIndex: number) => void;
+ projectCustomOptions?: FbObjCustomOption[];
+}
+
+function TutorialScenarioPage(props: Props) {
+ const {
+ tutorial,
+ screen,
+ screenIndex,
+ tasks,
+ results,
+ onScenarioResultsChange,
+ state,
+ attempts,
+ onScenarioSubmit,
+ onScenarioShowAnswers,
+ projectCustomOptions,
+ } = props;
+
+ const { t } = useTranslation('tutorialScreen');
+
+ const handleResultsChange = useCallback((
+ next: Results | ((prev: Results) => Results),
+ ) => {
+ onScenarioResultsChange(screenIndex, next);
+ }, [screenIndex, onScenarioResultsChange]);
+
+ const handleSubmit = useCallback(() => {
+ const correct = isScenarioCorrect(tutorial.projectType, tasks, results);
+ if (!correct) {
+ const isOutOfAttempts = attempts + 1 >= TUTORIAL_MAX_ATTEMPTS;
+ showAlert({
+ title: t('incorrectTitle'),
+ message: isOutOfAttempts
+ ? t('noAttemptsLeft')
+ : t('tryAgain'),
+ alertType: 'error',
+ });
+ }
+ onScenarioSubmit(screenIndex, correct);
+ }, [tutorial.projectType, tasks, results, attempts, screenIndex, onScenarioSubmit, t]);
+
+ const handleShowAnswers = useCallback(() => {
+ const referenceResults = getReferenceResults(tasks, tutorial.projectType);
+ onScenarioResultsChange(screenIndex, (prev) => ({ ...prev, ...referenceResults }));
+ onScenarioShowAnswers(screenIndex);
+ }, [tasks, tutorial.projectType, screenIndex, onScenarioResultsChange, onScenarioShowAnswers]);
+
+ const disabled = state === 'correct'
+ || state === 'skip-unlocked'
+ || state === 'answers-shown';
+
+ let session: React.ReactNode;
+ switch (tutorial.projectType) {
+ case PROJECT_TYPE_FIND:
+ case PROJECT_TYPE_COMPLETENESS:
+ session = (
+
+ );
+ break;
+ case PROJECT_TYPE_COMPARE:
+ session = (
+
+ );
+ break;
+ case PROJECT_TYPE_VALIDATE:
+ session = (
+
+ );
+ break;
+ case PROJECT_TYPE_LOCATE_FEATURES:
+ session = (
+
+ );
+ break;
+ default:
+ session = ;
+ break;
+ }
+
+ const showShowAnswersButton = attempts >= TUTORIAL_MAX_ATTEMPTS && state !== 'correct';
+ const actionButton = showShowAnswersButton ? (
+
+ ) : (
+
+ );
+
+ return (
+
+
+
+ {session}
+
+ {actionButton}
+
+ );
+}
+
+export default TutorialScenarioPage;
diff --git a/components/tutorial/UnsupportedTutorialSession.tsx b/components/tutorial/UnsupportedTutorialSession.tsx
new file mode 100644
index 0000000..f5279bd
--- /dev/null
+++ b/components/tutorial/UnsupportedTutorialSession.tsx
@@ -0,0 +1,35 @@
+import { useTranslation } from 'react-i18next';
+import { StyleSheet } from 'react-native';
+
+import BlockListView from '@/components/BlockListView';
+import Text from '@/components/Text';
+import {
+ SPACING_2XS,
+ SPACING_MD,
+} from '@/constants/dimensions';
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ padding: SPACING_MD,
+ justifyContent: 'center',
+ gap: SPACING_2XS,
+ },
+});
+
+function UnsupportedTutorialSession() {
+ const { t } = useTranslation('tutorialScreen');
+
+ return (
+
+
+ {t('unsupportedTitle')}
+
+
+ {t('unsupportedDescription')}
+
+
+ );
+}
+
+export default UnsupportedTutorialSession;
diff --git a/components/tutorial/ValidateInstructions.tsx b/components/tutorial/ValidateInstructions.tsx
new file mode 100644
index 0000000..d4a4278
--- /dev/null
+++ b/components/tutorial/ValidateInstructions.tsx
@@ -0,0 +1,64 @@
+import { useTranslation } from 'react-i18next';
+import { StyleSheet } from 'react-native';
+
+import BlockListView from '@/components/BlockListView';
+import Icon, { type IconName } from '@/components/Icon';
+import Text from '@/components/Text';
+import { FbObjCustomOption } from '@/utils/types';
+
+import InstructionRow from './InstructionRow';
+
+const ICON_PILL_SIZE = 50;
+
+const styles = StyleSheet.create({
+ pill: {
+ width: ICON_PILL_SIZE,
+ height: ICON_PILL_SIZE,
+ borderRadius: ICON_PILL_SIZE / 2,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+});
+
+interface Props {
+ customOptions?: FbObjCustomOption[];
+}
+
+function ValidateInstructions(props: Props) {
+ const { customOptions } = props;
+ const { t } = useTranslation('instructionsScreen');
+
+ const options = customOptions ?? [];
+
+ return (
+
+
+ {t('validateUseButtons')}
+
+ {options.length === 0 ? (
+
+ {t('validateNoOptions')}
+
+ ) : options.map((option) => (
+
+
+
+ )}
+ title={option.title}
+ description={option.description}
+ />
+ ))}
+
+ );
+}
+
+export default ValidateInstructions;
diff --git a/components/tutorial/ValidateTutorialSession.tsx b/components/tutorial/ValidateTutorialSession.tsx
new file mode 100644
index 0000000..4e56b50
--- /dev/null
+++ b/components/tutorial/ValidateTutorialSession.tsx
@@ -0,0 +1,98 @@
+import { useCallback } from 'react';
+import { StyleSheet } from 'react-native';
+
+import { type IconName } from '@/components/Icon';
+import IconButton from '@/components/IconButton';
+import InlineListView from '@/components/InlineListView';
+import MapTile from '@/components/MapTile';
+import { TutorialSessionProps } from '@/components/tutorial/types';
+import {
+ SPACING_2XS,
+ SPACING_3XS,
+ SPACING_XS,
+} from '@/constants/dimensions';
+import {
+ FeatureGeoJson,
+ PROJECT_TYPE_VALIDATE,
+} from '@/utils/types';
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ padding: SPACING_2XS,
+ gap: SPACING_XS,
+ },
+ tile: {
+ flexGrow: 1,
+ },
+ buttons: {
+ flexShrink: 0,
+ paddingVertical: SPACING_3XS,
+ },
+});
+
+function ValidateTutorialSession(props: TutorialSessionProps) {
+ const {
+ tutorial,
+ tasks,
+ results,
+ onResultsChange,
+ disabled,
+ } = props;
+
+ const handleSelect = useCallback((taskId: string, value: number) => {
+ if (disabled) {
+ return;
+ }
+ onResultsChange((prev) => ({
+ ...prev,
+ [taskId]: value,
+ }));
+ }, [disabled, onResultsChange]);
+
+ if (tutorial.projectType !== PROJECT_TYPE_VALIDATE) {
+ return null;
+ }
+
+ const { customOptions } = tutorial;
+ const task = tasks[0];
+
+ if (!task) {
+ return null;
+ }
+
+ const geoJson = 'geojson' in task ? task.geojson as FeatureGeoJson : undefined;
+ const selectedValue = results[task.taskId];
+
+ return (
+
+ {geoJson && (
+
+ )}
+
+ {customOptions?.map((option) => (
+ handleSelect(task.taskId, value)}
+ />
+ ))}
+
+
+ );
+}
+
+export default ValidateTutorialSession;
diff --git a/components/tutorial/types.ts b/components/tutorial/types.ts
new file mode 100644
index 0000000..78b7dc6
--- /dev/null
+++ b/components/tutorial/types.ts
@@ -0,0 +1,35 @@
+import { AnyTutorialTask } from '@/utils/tutorial';
+import {
+ FbInformationPage,
+ FbObjCustomOption,
+ FbScreen,
+ FbTutorial,
+ Results,
+} from '@/utils/types';
+
+export type ScenarioState =
+ | 'unanswered'
+ | 'correct'
+ | 'wrong'
+ | 'skip-unlocked'
+ | 'answers-shown';
+
+export type TutorialStage =
+ | { type: 'intro'; tutorial: FbTutorial }
+ | { type: 'info'; page: FbInformationPage }
+ | {
+ type: 'scenario';
+ screen: FbScreen;
+ screenIndex: number;
+ tasks: AnyTutorialTask[];
+ }
+ | { type: 'outro'; tutorial: FbTutorial };
+
+export interface TutorialSessionProps {
+ tutorial: FbTutorial;
+ tasks: AnyTutorialTask[];
+ results: Results;
+ onResultsChange: (next: Results | ((prev: Results) => Results)) => void;
+ disabled: boolean;
+ projectCustomOptions?: FbObjCustomOption[];
+}
diff --git a/constants/common.ts b/constants/common.ts
index 5f0a9c5..4eeb8a6 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 communityDashboardUrl = process.env.EXPO_PUBLIC_COMMUNITY_DASHBOARD_URL;
diff --git a/i18n.ts b/i18n.ts
index c4581ba..452509d 100644
--- a/i18n.ts
+++ b/i18n.ts
@@ -156,10 +156,13 @@ const resources: Record Promise>> = {
changePassword: () => import('./public/locales/en/changePassword.json'),
changeUserName: () => import('./public/locales/en/changeUserName.json'),
informationPage: () => import('./public/locales/en/informationPage.json'),
+ instructionsScreen: () => import('./public/locales/en/instructionsScreen.json'),
languageSelection: () => import('./public/locales/en/languageSelection.json'),
+ tutorialScreen: () => import('./public/locales/en/tutorialScreen.json'),
loadMoreScreen: () => import('./public/locales/en/loadMoreScreen.json'),
login: () => import('./public/locales/en/login.json'),
mainHeader: () => import('./public/locales/en/mainHeader.json'),
+ mappingSession: () => import('./public/locales/en/mappingSession.json'),
mappingHeader: () => import('./public/locales/en/mappingHeader.json'),
profileScreen: () => import('./public/locales/en/profileScreen.json'),
projectList: () => import('./public/locales/en/projectList.json'),
diff --git a/package.json b/package.json
index 2a1c9ba..370b58a 100644
--- a/package.json
+++ b/package.json
@@ -2,6 +2,7 @@
"name": "mapswipe-mobile",
"main": "expo-router/entry",
"version": "0.0.1",
+ "type": "module",
"scripts": {
"start": "expo start",
"android": "expo run:android",
diff --git a/public/locales/en/instructionsScreen.json b/public/locales/en/instructionsScreen.json
new file mode 100644
index 0000000..bdc2fcb
--- /dev/null
+++ b/public/locales/en/instructionsScreen.json
@@ -0,0 +1,34 @@
+{
+ "instructions": "Instructions",
+ "viewInstructions": "View instructions",
+ "youAreLookingFor": "You are looking for: {{lookFor}}",
+ "defaultLookFor": "the requested feature",
+ "tileGridIntro": "Use the tiles to answer the question. Here's how:",
+ "tileGridSwipe": "If there's nothing relevant in the images, simply <1>swipe1> to the next screen.",
+ "tileGridTapOnce": "If you see something in one of the images, <1>tap once1> and the tile turns green.",
+ "tileGridTapTwice": "Not sure about what you see? <1>Tap twice1> and the tile turns yellow.",
+ "tileGridTapThrice": "If there's an issue with the imagery, <1>tap three times1> and the tile turns red.",
+ "tileGridTapReset": "<1>Tap again1> to return the tile to its original state.",
+ "tileGridTapHold": "<1>Tap and hold1> to hide icons and overlay.",
+ "validateUseButtons": "Use the buttons below to answer the question.",
+ "validateDefaultQuestion": "Does the shape outline the feature?",
+ "validateNoOptions": "This project doesn't yet have answer options to display.",
+ "compareYourTask": "Your task",
+ "compareLookingFor": "You're looking for <1>changes in buildings1>. This acts as a clear indicator for a change in population size.",
+ "comparePerformTask": "How to perform the task",
+ "compareNoChanges": "If there are no changes, simply <1>swipe1> to the next photos.",
+ "compareSeeChanges": "If you see a change in buildings, <1>tap once1> and the tile turns green.",
+ "compareUnsure": "Unsure? <1>Tap twice1> and the tile turns yellow.",
+ "compareBadImagery": "Imagery issue, like clouds covering the view? <1>Tap three times1> and the tile turns red.",
+ "compareHideIcons": "<1>Tap and hold1> to hide icons and overlay.",
+ "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",
+ "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
new file mode 100644
index 0000000..f16a63c
--- /dev/null
+++ b/public/locales/en/mappingSession.json
@@ -0,0 +1,18 @@
+{
+ "sessionCompleteTitle": "Great work!",
+ "sessionCompleteMessage": "You've finished this mapping session. Choose what to do next:",
+ "savingProgress": "Saving your progress…",
+ "saveFailed": "We couldn't save your progress. Please check your connection and try again.",
+ "saveSucceeded": "Your progress has been saved.",
+ "continueMapping": "Continue mapping",
+ "continueMappingHelp": "Save your contributions and start a new group of tasks for this project.",
+ "completeSession": "Complete session",
+ "completeSessionHelp": "Save your contributions and return to the home screen.",
+ "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.",
+ "enterSelectionMode": "Enter selection mode",
+ "cycleSelectedCells": "Cycle value of selected cells",
+ "exitSelectionMode": "Exit selection mode"
+}
diff --git a/public/locales/en/tutorialScreen.json b/public/locales/en/tutorialScreen.json
new file mode 100644
index 0000000..8521720
--- /dev/null
+++ b/public/locales/en/tutorialScreen.json
@@ -0,0 +1,16 @@
+{
+ "pageTitle": "Tutorial",
+ "swipeThroughIntro": "Swipe through the next pages to learn how this tutorial works, practise on a few example tasks, then start mapping for real.",
+ "readyToMap": "You're ready to map {{name}}.",
+ "outroMessage": "Great work! You've finished the tutorial. Head back to the project to start mapping for real.",
+ "backToProject": "Back to project",
+ "unsupportedTitle": "Practice not yet available",
+ "unsupportedDescription": "Interactive practice for this project type is coming soon. Read the hint and tap Submit to continue.",
+ "incorrectTitle": "Incorrect",
+ "noAttemptsLeft": "No attempts left. Tap Show answers to reveal the correct selection.",
+ "tryAgain": "That doesn't look right. Please try again.",
+ "showAnswers": "Show answers",
+ "checkAnswer": "Check answer ({{attempts}}/{{max}})",
+ "compareBefore": "Before",
+ "compareAfter": "After"
+}
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
new file mode 100644
index 0000000..85e27b7
--- /dev/null
+++ b/utils/tutorial.ts
@@ -0,0 +1,173 @@
+import { isNotDefined } from '@togglecorp/fujs';
+import { decode } from 'base-64';
+import { inflate } from 'pako';
+
+import {
+ FbCompareTutorialTask,
+ FbCompletenessTutorialTask,
+ FbFindTutorialTask,
+ FbStreetTutorialTask,
+ FbTileMapServiceTutorialTask,
+ FbValidateImageTutorialTask,
+ FbValidateTutorialTask,
+ PROJECT_TYPE_LOCATE_FEATURES,
+ PROJECT_TYPE_STREET,
+ PROJECT_TYPE_VALIDATE_IMAGE,
+ Results,
+} from './types';
+
+export const TUTORIAL_MAX_ATTEMPTS = 3;
+
+export type TileTutorialTask = FbTileMapServiceTutorialTask
+ & Partial;
+
+export type AnyTutorialTask =
+ | TileTutorialTask
+ | FbValidateTutorialTask
+ | FbValidateImageTutorialTask
+ | FbStreetTutorialTask;
+
+export function decompressTasks(value: string | T[] | undefined | null): T[] {
+ if (isNotDefined(value)) {
+ return [];
+ }
+ if (Array.isArray(value)) {
+ return value;
+ }
+ if (typeof value !== 'string') {
+ return [];
+ }
+
+ const decodedStr = decode(value);
+ const charList = decodedStr.split('').map((c) => c.charCodeAt(0));
+ const binary = new Uint8Array(charList);
+ const decompressed = inflate(binary, { to: 'string' });
+ return JSON.parse(decompressed) as T[];
+}
+
+function getScreenIndex(task: AnyTutorialTask): number | undefined {
+ if ('properties' in task && task.properties
+ && typeof task.properties.screen === 'number') {
+ return task.properties.screen;
+ }
+ if ('screen' in task && typeof task.screen === 'number') {
+ return task.screen;
+ }
+ return undefined;
+}
+
+export function groupTasksByScreen(
+ tasks: T[] | undefined,
+): Record {
+ if (isNotDefined(tasks)) {
+ return {};
+ }
+ return tasks.reduce>((acc, task) => {
+ const screen = getScreenIndex(task);
+ if (isNotDefined(screen)) {
+ return acc;
+ }
+ if (!acc[screen]) {
+ acc[screen] = [];
+ }
+ acc[screen].push(task);
+ return acc;
+ }, {});
+}
+
+function getReferenceForTask(task: AnyTutorialTask): number | undefined {
+ if ('properties' in task && task.properties
+ && typeof task.properties.reference === 'number') {
+ return task.properties.reference;
+ }
+ if ('referenceAnswer' in task && typeof task.referenceAnswer === 'number') {
+ return task.referenceAnswer;
+ }
+ return undefined;
+}
+
+// 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);
+ if (reference !== undefined) {
+ result[task.taskId] = reference;
+ }
+ });
+ return result;
+}
+
+export function isScenarioCorrect(
+ projectType: number,
+ tasks: AnyTutorialTask[],
+ results: Results,
+): boolean {
+ if (projectType === PROJECT_TYPE_VALIDATE_IMAGE || projectType === PROJECT_TYPE_STREET) {
+ return true;
+ }
+ 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)) {
+ return true;
+ }
+ return results[task.taskId] === reference;
+ });
+}
diff --git a/utils/types.ts b/utils/types.ts
index acbe529..afe2813 100644
--- a/utils/types.ts
+++ b/utils/types.ts
@@ -1,526 +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;
- 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;
- 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;
@@ -528,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;
@@ -556,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
@@ -570,6 +95,7 @@ export type FbTutorial = FbBaseTutorial & (
| FbValidateImageTutorial
| FbCompletenessTutorial
| FbStreetTutorial
+ | FbLocateTutorial
);
export type ValidateTask = FbMappingTaskCreateOnlyInput & FbMappingTaskValidateCreateOnlyInput;
@@ -590,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 ?? '',
},
});
};