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