Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app.prod.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"userInterfaceStyle": "light",
"ios": {
"bundleIdentifier": "org.missingmaps.mapswipe",
"buildNumber": "12",
"buildNumber": "13",
"backgroundColor": "#0D1949",
"supportsTablet": true,
"infoPlist": {
Expand Down
2 changes: 1 addition & 1 deletion app.staging.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"userInterfaceStyle": "light",
"ios": {
"bundleIdentifier": "org.missingmaps.mapswipe-dev",
"buildNumber": "12",
"buildNumber": "13",
"backgroundColor": "#0D1949",
"supportsTablet": true,
"infoPlist": {
Expand Down
2 changes: 2 additions & 0 deletions app/(auth)/project/[id]/map/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ function MapProjectIndex() {
</Text>
<Button
name="back-to-projects"
colorVariant="primaryRed"
styleVariant="filled"
title="Back to projects"
onPress={() => router.replace('/projects')}
/>
Expand Down
12 changes: 10 additions & 2 deletions components/ChangeLogModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ function ChangeLogModal() {
return;
}
AsyncStorage.getItem(LAST_VIEWED_CHANGELOG_VERSION_KEY).then((value) => {
if (value === null) {
AsyncStorage.setItem(
LAST_VIEWED_CHANGELOG_VERSION_KEY,
currentVersion,
);
return;
}
if (value !== currentVersion) {
setVisible(true);
}
Expand Down Expand Up @@ -89,8 +96,9 @@ function ChangeLogModal() {
nestedScrollEnabled
>
<BlockListView spacing="xs">
{currentVersionChanges.map((change) => (
<View key={change} style={styles.changeRow}>
{currentVersionChanges.map((change, index) => (
// eslint-disable-next-line react/no-array-index-key
<View key={index} style={styles.changeRow}>
<Text>-</Text>
<Text>{change}</Text>
</View>
Expand Down
29 changes: 28 additions & 1 deletion components/LocateFeaturesMappingSession.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import {
cloneElement,
Dispatch,
isValidElement,
type ReactElement,
type ReactNode,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
Expand Down Expand Up @@ -192,6 +196,8 @@ function LocateFeaturesMappingSession(props: Props) {
return buildTasks(projectDetails, groupDetails);
}, [groupDetails, projectDetails]);

const flatListRef = useRef<FlatList<typeof tasks[number]>>(null);

const options = useMemo<ResultOption[]>(() => {
if (isDefined(projectDetails.customOptions) && projectDetails.customOptions.length > 0) {
return projectDetails.customOptions.map((option) => ({
Expand Down Expand Up @@ -366,6 +372,18 @@ function LocateFeaturesMappingSession(props: Props) {
}
}, [pageWidth, tasks.length, onReachedEnd]);

// "Go Back" on the outro scrolls back to the last task so the user can
// revise their answer (the outro is the page after the final task).
const handleOutroGoBack = useCallback(() => {
if (tasks.length === 0) {
return;
}
flatListRef.current?.scrollToOffset({
offset: (tasks.length - 1) * pageWidth,
animated: true,
});
}, [tasks.length, pageWidth]);

const tileWidth = Math.min(pageWidth - 20, pageHeight / 2);
// The tile is vertically centered in the viewport, so its bottom edge sits
// at (viewportHeight + tileHeight) / 2. Anchor the options bar just below
Expand Down Expand Up @@ -441,6 +459,7 @@ function LocateFeaturesMappingSession(props: Props) {
</InlineListView>
</View>
<FlatList
ref={flatListRef}
data={tasks}
style={styles.list}
contentContainerStyle={styles.content}
Expand Down Expand Up @@ -489,7 +508,15 @@ function LocateFeaturesMappingSession(props: Props) {
height: viewportHeight || undefined,
})}
>
{completionPage}
{isValidElement(completionPage)
? cloneElement(
completionPage as ReactElement<{ onGoBack?: () => void }>,
// handleOutroGoBack only reads the FlatList ref when
// invoked (on button press), never during render.
// eslint-disable-next-line react-hooks/refs
{ onGoBack: handleOutroGoBack },
)
: completionPage}
</View>
) : null}
horizontal
Expand Down
29 changes: 28 additions & 1 deletion components/TileGridMappingSession.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import {
cloneElement,
Dispatch,
isValidElement,
type ReactElement,
type ReactNode,
SetStateAction,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
Expand Down Expand Up @@ -205,6 +209,8 @@ function TileGridMappingSession(props: Props) {
);
}, [tasks]);

const flatListRef = useRef<FlatList<typeof groupedTasks[number]>>(null);

const tileWidth = Math.min(pageWidth / 2, pageHeight / 4);

// The completion page is appended as a full-width page after the tile
Expand Down Expand Up @@ -234,6 +240,18 @@ function TileGridMappingSession(props: Props) {
}
}, [pageWidth, groupedTasks.length, contentPages, onReachedEnd]);

// "Go Back" on the outro scrolls back to the last task page so the user can
// revise their answers (the outro is the page after the final tasks).
const handleOutroGoBack = useCallback(() => {
if (contentPages === 0) {
return;
}
flatListRef.current?.scrollToOffset({
offset: (contentPages - 1) * pageWidth,
animated: true,
});
}, [contentPages, pageWidth]);

const handleTilePress = useCallback((taskId: string) => {
onResultsChange((prevResults) => {
const prevValue = prevResults[taskId];
Expand Down Expand Up @@ -312,6 +330,7 @@ function TileGridMappingSession(props: Props) {
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<View style={styles.tileGridWrapper} {...swipeResponder.panHandlers}>
<FlatList
ref={flatListRef}
data={groupedTasks}
contentContainerStyle={styles.content}
keyExtractor={(groupedTaskItem) => groupedTaskItem.taskX}
Expand Down Expand Up @@ -365,7 +384,15 @@ function TileGridMappingSession(props: Props) {
<View style={{ width: completionLeftFiller }} />
)}
<View style={{ width: pageWidth }}>
{completionPage}
{isValidElement(completionPage)
? cloneElement(
completionPage as ReactElement<{ onGoBack?: () => void }>,
// handleOutroGoBack only reads the FlatList ref when
// invoked (on button press), never during render.
// eslint-disable-next-line react-hooks/refs
{ onGoBack: handleOutroGoBack },
)
: completionPage}
</View>
</View>
) : null}
Expand Down
126 changes: 101 additions & 25 deletions components/tutorial/LocateTutorialSession.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { useTranslation } from 'react-i18next';
import {
StyleSheet,
TouchableOpacity,
useWindowDimensions,
View,
} from 'react-native';
Expand All @@ -19,12 +20,12 @@ import {
import {
CheckIcon,
SelectionIcon,
SkipForwardIcon,
} from 'phosphor-react-native';

import Button from '@/components/Button';
import InlineListView from '@/components/InlineListView';
import LocateTile from '@/components/LocateTile';
import Text from '@/components/Text';
import { TutorialSessionProps } from '@/components/tutorial/types';
import useTheme from '@/hooks/useTheme';
import { TileTutorialTask } from '@/utils/tutorial';
Expand Down Expand Up @@ -53,6 +54,38 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
},
// Centered options bar shown in selection mode, mirroring the mapping
// session: pick an option to apply it to every selected cell.
optionsBarContainer: {
flexGrow: 0,
flexShrink: 0,
alignItems: 'center',
},
optionsBar: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'center',
gap: 8,
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 16,
borderWidth: 1,
maxWidth: '92%',
},
optionChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 8,
paddingHorizontal: 14,
borderRadius: 999,
},
optionDot: {
width: 12,
height: 12,
borderRadius: 6,
},
});

const DEFAULT_OPTIONS: ResultOption[] = [
Expand Down Expand Up @@ -215,7 +248,9 @@ function LocateTutorialSession(props: TutorialSessionProps) {
setSelectedCellsByTile({});
}, []);

const handleCycleSelected = useCallback(() => {
// Applies the chosen option's value to every selected cell (across tiles),
// then clears the selection so the next batch can be selected fresh.
const handleApplyOptionToSelected = useCallback((value: number) => {
const tilesWithSelection = Object.entries(selectedCellsByTile)
.filter(([, cells]) => cells.length > 0);
if (tilesWithSelection.length === 0) {
Expand All @@ -230,13 +265,20 @@ function LocateTutorialSession(props: TutorialSessionProps) {
: new Array<number>(cellsPerTile).fill(defaultCellValue);
const nextCells = [...prevCells];
cells.forEach((idx) => {
nextCells[idx] = getNextValue(prevCells[idx]);
nextCells[idx] = value;
});
next[tileKey] = nextCells;
});
return next;
});
}, [selectedCellsByTile, onResultsChange, cellsPerTile, defaultCellValue, getNextValue]);
setSelectedCellsByTile({});
}, [selectedCellsByTile, onResultsChange, cellsPerTile, defaultCellValue]);

const selectedCount = useMemo(
() => Object.values(selectedCellsByTile)
.reduce((sum, cells) => sum + cells.length, 0),
[selectedCellsByTile],
);

const tileWidth = useMemo(() => {
const availWidth = tileSlotSize.width || (pageWidth - 24);
Expand All @@ -260,27 +302,16 @@ function LocateTutorialSession(props: TutorialSessionProps) {
</Button>
)}
{mode === 'selection' && (
<>
<Button
name="cycle-selected"
accessibilityLabel={t('cycleSelectedCells')}
styleVariant="action"
fullWidth={false}
onPress={handleCycleSelected}
>
<SkipForwardIcon color={theme.textOnBrand} />
</Button>
<Button
name="exit-selection"
accessibilityLabel={t('exitSelectionMode')}
colorVariant="primaryRed"
styleVariant="action"
fullWidth={false}
onPress={handleExitSelectionMode}
>
<CheckIcon color={theme.textOnBrand} />
</Button>
</>
<Button
name="exit-selection"
accessibilityLabel={t('exitSelectionMode')}
colorVariant="primaryRed"
styleVariant="action"
fullWidth={false}
onPress={handleExitSelectionMode}
>
<CheckIcon color={theme.textOnBrand} />
</Button>
)}
</InlineListView>
)}
Expand Down Expand Up @@ -329,6 +360,51 @@ function LocateTutorialSession(props: TutorialSessionProps) {
);
})}
</View>
{effectiveSelectionMode && (
<View style={styles.optionsBarContainer} pointerEvents="box-none">
<View
style={StyleSheet.flatten([
styles.optionsBar,
{
backgroundColor: theme.backgroundBrand,
borderColor: theme.divider,
},
])}
>
{options.map((option) => {
const dotColor = option.color === 'transparent'
? theme.textMuted
: option.color;
return (
<TouchableOpacity
key={option.value}
style={StyleSheet.flatten([
styles.optionChip,
{
backgroundColor: theme.inputBrandBackground,
opacity: selectedCount === 0 ? 0.5 : 1,
},
])}
onPress={() => handleApplyOptionToSelected(option.value)}
disabled={selectedCount === 0}
accessibilityRole="button"
accessibilityLabel={option.label}
>
<View
style={StyleSheet.flatten([
styles.optionDot,
{ backgroundColor: dotColor },
])}
/>
<Text variant="label" colorVariant="brand">
{option.label}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
)}
</View>
);
}
Expand Down
1 change: 0 additions & 1 deletion public/locales/en/mappingSession.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,5 @@
"discardSession": "Discard session",
"discardSessionHelp": "Throw away your answers from this session and return to the home screen.",
"enterSelectionMode": "Enter selection mode",
"cycleSelectedCells": "Cycle value of selected cells",
"exitSelectionMode": "Exit selection mode"
}
Loading