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')} + + + )} + + + )} + {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')} + +