diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 598644521..7d3304897 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1745,7 +1745,7 @@ SPEC CHECKSUMS: RNSensors: 117ba71c7eeeea0407ea0c0bb79e3495d602049b RNSVG: ba3e7232f45e34b7b47e74472386cf4e1a676d0a SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17 - Yoga: 64cd2a583ead952b0315d5135bf39e053ae9be70 + Yoga: 1b901a6d6eeba4e8a2e8f308f708691cdb5db312 YogaKit: f782866e155069a2cca2517aafea43200b01fd5a ZIPFoundation: d170fa8e270b2a32bef9dcdcabff5b8f1a5deced diff --git a/src/entities/activity/lib/types/activity.ts b/src/entities/activity/lib/types/activity.ts index 8e6dcd344..a82d44614 100644 --- a/src/entities/activity/lib/types/activity.ts +++ b/src/entities/activity/lib/types/activity.ts @@ -80,6 +80,7 @@ type CheckboxConfig = { setAlerts: boolean; addTooltip: boolean; setPalette: boolean; + isGridView: boolean; options: Array<{ id: string; text: string; @@ -190,6 +191,7 @@ type RadioConfig = { addTooltip: boolean; setPalette: boolean; autoAdvance: boolean; + isGridView: boolean; options: Array<{ id: string; text: string; diff --git a/src/entities/activity/model/mappers.input.mock.ts b/src/entities/activity/model/mappers.input.mock.ts index d789d568a..0d2ef67be 100644 --- a/src/entities/activity/model/mappers.input.mock.ts +++ b/src/entities/activity/model/mappers.input.mock.ts @@ -677,6 +677,7 @@ export const conditionalInput: ActivityDto = { textInputRequired: false, }, autoAdvance: true, + portraitLayout: false, }, name: 'phq9_instructions', isHidden: false, @@ -717,6 +718,7 @@ export const conditionalInput: ActivityDto = { textInputRequired: false, }, autoAdvance: true, + portraitLayout: false, }, name: 'phq9_01', isHidden: false, @@ -758,6 +760,7 @@ export const conditionalInput: ActivityDto = { textInputRequired: false, }, autoAdvance: true, + portraitLayout: false, }, name: 'phq9_02', isHidden: false, @@ -799,6 +802,7 @@ export const conditionalInput: ActivityDto = { textInputRequired: false, }, autoAdvance: true, + portraitLayout: false, }, name: 'phq9_03', isHidden: false, @@ -864,6 +868,7 @@ export const conditionalInput: ActivityDto = { textInputRequired: false, }, autoAdvance: true, + portraitLayout: false, }, name: 'phq9_04', isHidden: false, @@ -914,6 +919,7 @@ export const conditionalInput: ActivityDto = { textInputRequired: false, }, autoAdvance: true, + portraitLayout: false, }, name: 'phq9_05', isHidden: false, @@ -964,6 +970,7 @@ export const conditionalInput: ActivityDto = { textInputRequired: false, }, autoAdvance: true, + portraitLayout: false, }, name: 'phq9_06', isHidden: false, @@ -1014,6 +1021,7 @@ export const conditionalInput: ActivityDto = { textInputRequired: false, }, autoAdvance: true, + portraitLayout: false, }, name: 'phq9_07', isHidden: false, @@ -1127,6 +1135,7 @@ export const checkboxInput: ActivityDto = { setAlerts: false, addTooltip: false, randomizeOptions: false, + portraitLayout: false, }, name: 'Screen2', isHidden: false, diff --git a/src/entities/activity/model/mappers.output.mock.ts b/src/entities/activity/model/mappers.output.mock.ts index 6d49fd0f8..b4a78f44e 100644 --- a/src/entities/activity/model/mappers.output.mock.ts +++ b/src/entities/activity/model/mappers.output.mock.ts @@ -774,6 +774,7 @@ export const conditionalOutput: ActivityDetails = { randomizeOptions: false, setAlerts: false, setPalette: false, + isGridView: false, }, hasAlert: false, hasScore: false, @@ -811,6 +812,7 @@ export const conditionalOutput: ActivityDetails = { randomizeOptions: false, setAlerts: false, setPalette: false, + isGridView: false, }, hasAlert: false, hasScore: false, @@ -847,6 +849,7 @@ export const conditionalOutput: ActivityDetails = { randomizeOptions: false, setAlerts: false, setPalette: false, + isGridView: false, }, hasAlert: false, hasScore: false, @@ -917,6 +920,7 @@ export const conditionalOutput: ActivityDetails = { randomizeOptions: false, setAlerts: false, setPalette: false, + isGridView: false, }, hasAlert: false, hasScore: false, @@ -966,6 +970,7 @@ export const conditionalOutput: ActivityDetails = { randomizeOptions: false, setAlerts: false, setPalette: false, + isGridView: false, }, hasAlert: false, hasScore: false, @@ -1015,6 +1020,7 @@ export const conditionalOutput: ActivityDetails = { randomizeOptions: false, setAlerts: false, setPalette: false, + isGridView: false, }, hasAlert: false, hasScore: false, @@ -1064,6 +1070,7 @@ export const conditionalOutput: ActivityDetails = { randomizeOptions: false, setAlerts: false, setPalette: false, + isGridView: false, }, hasAlert: false, hasScore: false, @@ -1120,6 +1127,7 @@ export const conditionalOutput: ActivityDetails = { randomizeOptions: false, setAlerts: false, setPalette: false, + isGridView: false, }, hasAlert: false, hasScore: false, @@ -1235,6 +1243,7 @@ export const checkboxOutput: ActivityDetails = { randomizeOptions: false, setAlerts: false, setPalette: false, + isGridView: false, }, hasAlert: false, hasScore: false, diff --git a/src/entities/activity/model/mappers.ts b/src/entities/activity/model/mappers.ts index 9f36c3d81..a1b5a4cf6 100644 --- a/src/entities/activity/model/mappers.ts +++ b/src/entities/activity/model/mappers.ts @@ -361,6 +361,7 @@ function mapToCheckbox(dto: MultiSelectionItemDto): ActivityItem { setAlerts: dto.config.setAlerts, addTooltip: dto.config.addTooltip, setPalette: dto.config.setPalette, + isGridView: dto.config.portraitLayout, options: mapToCheckboxOptions(dto.responseValues.options), }, timer: mapTimerValue(dto.config.timer), @@ -464,6 +465,7 @@ function mapToRadio(dto: SingleSelectionItemDto): ActivityItem { autoAdvance: dto.config.autoAdvance, setPalette: dto.config.setPalette, options: mapToRadioAlerts(dto.responseValues.options), + isGridView: dto.config.portraitLayout, }, timer: mapTimerValue(dto.config.timer), order: dto.order, diff --git a/src/entities/applet/model/tests/mappers.input.mock.ts b/src/entities/applet/model/tests/mappers.input.mock.ts index 4055e25fc..d36370e49 100644 --- a/src/entities/applet/model/tests/mappers.input.mock.ts +++ b/src/entities/applet/model/tests/mappers.input.mock.ts @@ -154,6 +154,7 @@ const activityItems: ActivityItemDto[] = [ setAlerts: false, addTooltip: false, randomizeOptions: false, + portraitLayout: false, }, name: 'Screen2', isHidden: false, @@ -244,6 +245,7 @@ const activityItems: ActivityItemDto[] = [ textInputRequired: false, }, autoAdvance: true, + portraitLayout: false, }, name: 'name', isHidden: false, diff --git a/src/features/pass-survey/lib/types/payload.ts b/src/features/pass-survey/lib/types/payload.ts index 9a5a30a81..c9c23d1ef 100644 --- a/src/features/pass-survey/lib/types/payload.ts +++ b/src/features/pass-survey/lib/types/payload.ts @@ -171,6 +171,7 @@ type RadioPayload = { addTooltip: boolean; setPalette: boolean; autoAdvance: boolean; + isGridView: boolean; options: Array<{ id: string; text: string; @@ -191,6 +192,7 @@ type CheckboxPayload = { setAlerts: boolean; addTooltip: boolean; setPalette: boolean; + isGridView: boolean; options: Array<{ id: string; text: string; diff --git a/src/features/pass-survey/model/hooks/mockActivities.ts b/src/features/pass-survey/model/hooks/mockActivities.ts index f5b484fb0..6bcbff927 100644 --- a/src/features/pass-survey/model/hooks/mockActivities.ts +++ b/src/features/pass-survey/model/hooks/mockActivities.ts @@ -552,6 +552,7 @@ export const CheckboxTestActivity: ActivityDto = { skippableItem: false, timer: null, addScores: false, + portraitLayout: false, }, conditionalLogic: null, }, @@ -687,6 +688,7 @@ export const RadioTestActivity: ActivityDto = { addTooltip: true, setPalette: true, autoAdvance: true, + portraitLayout: false, }, responseType: 'singleSelect', name: 'radioName', @@ -863,6 +865,7 @@ export const AllCheckboxesActivity: ActivityDto = { skippableItem: false, timer: null, addScores: false, + portraitLayout: false, }, conditionalLogic: null, }, @@ -925,6 +928,7 @@ export const AllCheckboxesActivity: ActivityDto = { skippableItem: true, timer: null, addScores: false, + portraitLayout: false, }, }, { @@ -985,6 +989,7 @@ export const AllCheckboxesActivity: ActivityDto = { skippableItem: false, timer: null, addScores: false, + portraitLayout: false, }, conditionalLogic: null, }, @@ -1122,6 +1127,7 @@ export const AllRadioActivity: ActivityDto = { addTooltip: true, setPalette: true, autoAdvance: true, + portraitLayout: false, }, responseType: 'singleSelect', name: 'radioName', @@ -1240,6 +1246,7 @@ export const AllRadioActivity: ActivityDto = { addTooltip: false, setPalette: false, autoAdvance: true, + portraitLayout: false, }, responseType: 'singleSelect', name: 'radioName', diff --git a/src/features/pass-survey/model/tests/ScoresCalculator.collectScoreForCheckboxes.test.ts b/src/features/pass-survey/model/tests/ScoresCalculator.collectScoreForCheckboxes.test.ts index 6bd5fa28f..8739557b3 100644 --- a/src/features/pass-survey/model/tests/ScoresCalculator.collectScoreForCheckboxes.test.ts +++ b/src/features/pass-survey/model/tests/ScoresCalculator.collectScoreForCheckboxes.test.ts @@ -36,6 +36,7 @@ const getEmptyItem = (): CheckboxPipelineItem => { randomizeOptions: false, setAlerts: false, setPalette: false, + isGridView: false, options: [], }, type: 'Checkbox', diff --git a/src/features/pass-survey/model/tests/ScoresCalculator.collectScoreForRadio.test.ts b/src/features/pass-survey/model/tests/ScoresCalculator.collectScoreForRadio.test.ts index 0a7695066..f1129a569 100644 --- a/src/features/pass-survey/model/tests/ScoresCalculator.collectScoreForRadio.test.ts +++ b/src/features/pass-survey/model/tests/ScoresCalculator.collectScoreForRadio.test.ts @@ -35,6 +35,7 @@ const getEmptyItem = (): RadioPipelineItem => { setPalette: false, options: [], autoAdvance: false, + isGridView: false, }, type: 'Radio', }; diff --git a/src/features/pass-survey/model/tests/ScoresExtractor.test.ts b/src/features/pass-survey/model/tests/ScoresExtractor.test.ts index 8fba6825a..a440ff770 100644 --- a/src/features/pass-survey/model/tests/ScoresExtractor.test.ts +++ b/src/features/pass-survey/model/tests/ScoresExtractor.test.ts @@ -31,6 +31,7 @@ const getRadiosItem = (): RadioPipelineItem => { setPalette: false, setAlerts: false, randomizeOptions: false, + isGridView: false, options: [ { alert: null, @@ -81,6 +82,7 @@ const getCheckboxesItem = (): CheckboxPipelineItem => { randomizeOptions: false, setAlerts: false, setPalette: false, + isGridView: false, options: [ { alert: null, diff --git a/src/features/pass-survey/model/tests/testHelpers.ts b/src/features/pass-survey/model/tests/testHelpers.ts index 06900091d..5e1da9614 100644 --- a/src/features/pass-survey/model/tests/testHelpers.ts +++ b/src/features/pass-survey/model/tests/testHelpers.ts @@ -30,6 +30,7 @@ export const getEmptyRadioItem = (name: string): RadioPipelineItem => { setPalette: false, options: [], autoAdvance: false, + isGridView: false, }, type: 'Radio', }; @@ -80,6 +81,7 @@ export const getEmptyCheckboxesItem = (name: string): CheckboxPipelineItem => { randomizeOptions: false, setAlerts: false, setPalette: false, + isGridView: false, options: [], }, type: 'Checkbox', diff --git a/src/features/pass-survey/ui/ActivityItem.tsx b/src/features/pass-survey/ui/ActivityItem.tsx index 7254067d5..1f7703b15 100644 --- a/src/features/pass-survey/ui/ActivityItem.tsx +++ b/src/features/pass-survey/ui/ActivityItem.tsx @@ -379,7 +379,7 @@ function ActivityItem({ } return ( - + ( return [leftArray, rightArray]; } +export function chunkArray( + array: Array, + size: number, +): Array> { + if (array.length === 0) { + return []; + } + + return array.reduce>>((acc, val) => { + if (acc.length === 0) { + acc.push([]); + } + + const lastArray = acc[acc.length - 1]; + + if (lastArray.length < size) { + lastArray.push(val); + } else { + acc.push([val]); + } + + return acc; + }, []); +} + export const getFloatPartLength = (numberValue: number) => { const numberAsString = numberValue.toString(); diff --git a/src/shared/lib/utils/survey/survey.ts b/src/shared/lib/utils/survey/survey.ts index a6b3cf119..d81862cb2 100644 --- a/src/shared/lib/utils/survey/survey.ts +++ b/src/shared/lib/utils/survey/survey.ts @@ -9,7 +9,18 @@ import { colors } from '@shared/lib/constants'; import { getNow } from '../dateTime'; -export const invertColor = (hex: string) => { +type ColorModes = { + dark: string; + light: string; +}; + +export const invertColor = ( + hex: string, + colorModes: ColorModes = { + dark: colors.darkerGrey, + light: colors.white, + }, +) => { const RED_RATIO = 299; const GREEN_RATIO = 587; const BLUE_RATIO = 114; @@ -19,7 +30,7 @@ export const invertColor = (hex: string) => { const blue = parseInt(hexColor.substring(4, 6), 16); const yiqColorSpaceValue = (red * RED_RATIO + green * GREEN_RATIO + blue * BLUE_RATIO) / 1000; - return yiqColorSpaceValue >= 128 ? colors.darkerGrey : colors.white; + return yiqColorSpaceValue >= 128 ? colorModes.light : colorModes.dark; }; export const getEntityProgress = ( diff --git a/src/shared/lib/utils/tests/common.test.ts b/src/shared/lib/utils/tests/common.test.ts index 499d1505f..84a94ca68 100644 --- a/src/shared/lib/utils/tests/common.test.ts +++ b/src/shared/lib/utils/tests/common.test.ts @@ -1,6 +1,7 @@ import { callWithMutex, callWithMutexAsync, + chunkArray, getStringHashCode, isObjectNotEmpty, Mutex, @@ -92,3 +93,60 @@ describe('Test function getStringHashCode', () => { expect(result).toBe(expectedResult); }); }); + +describe('Test function chunkArray', () => { + const testCases = [ + { + input: { + array: [], + chunks: 5, + }, + expected: [], + }, + { + input: { + array: [1, 2, 3], + chunks: 3, + }, + expected: [[1, 2, 3]], + }, + { + input: { + array: [1, 2, 3], + chunks: 1, + }, + expected: [[1], [2], [3]], + }, + { + input: { + array: [1, 2, 3, 4, 5, 6], + chunks: 2, + }, + expected: [ + [1, 2], + [3, 4], + [5, 6], + ], + }, + { + input: { + array: [1, 2, 3, 4, 5, 6, 7], + chunks: 2, + }, + expected: [[1, 2], [3, 4], [5, 6], [7]], + }, + ]; + + const stringify = (array: number[] | Array) => + JSON.stringify(array); + + testCases.forEach(testCase => { + const expectedResult = stringify(testCase.expected); + + it(`should return ${expectedResult} when an array is ${stringify(testCase.input.array)} and chunks is ${testCase.input.chunks}`, () => { + const result = chunkArray(testCase.input.array, testCase.input.chunks); + + expect(stringify(result)).toBe(expectedResult); + }); + }); +}); diff --git a/src/shared/ui/OptionCard.tsx b/src/shared/ui/OptionCard.tsx new file mode 100644 index 000000000..7b743d6c8 --- /dev/null +++ b/src/shared/ui/OptionCard.tsx @@ -0,0 +1,106 @@ +import { PropsWithChildren, useState } from 'react'; +import { StyleSheet } from 'react-native'; + +import { CachedImage } from '@georstat/react-native-image-cache'; +import { styled } from '@tamagui/core'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; + +import { colors } from '@app/shared/lib'; + +import { Box, BoxProps, XStack, YStack, Text } from './'; + +type Props = { + imageUrl: string | null; + textColor?: string; + onPress: () => void; + renderLeftIcon?: () => JSX.Element | null; + renderRightIcon?: () => JSX.Element | null; +} & BoxProps; + +const CardWrapper = styled(YStack, { + minHeight: 188, + borderWidth: 2, + borderRadius: 12, + borderColor: colors.lighterGrey7, + backgroundColor: colors.white, +}); + +const Backdrop = styled(Box, { + flex: 1, + backgroundColor: 'rgba(26, 28, 30, 0.12))', +}); + +function OptionCard({ + children, + + imageUrl, + textColor = colors.onSurface, + + onPress, + renderLeftIcon, + renderRightIcon, + + ...styledProps +}: PropsWithChildren) { + const [backdropShown, setBackdropShown] = useState(false); + + return ( + setBackdropShown(true)} + onPressOut={() => setBackdropShown(false)} + p={12} + {...styledProps} + > + {backdropShown && ( + + + + )} + + {imageUrl && ( + + + + + + )} + + + {renderLeftIcon?.()} + + + {children} + + + {renderRightIcon?.()} + + + ); +} + +const styles = StyleSheet.create({ + image: { + height: '100%', + width: '100%', + }, +}); + +export default OptionCard; diff --git a/src/shared/ui/ScrollableContent.tsx b/src/shared/ui/ScrollableContent.tsx index f105de912..152b2b22e 100644 --- a/src/shared/ui/ScrollableContent.tsx +++ b/src/shared/ui/ScrollableContent.tsx @@ -23,11 +23,16 @@ import { IS_SMALL_SIZE_SCREEN, ScrollViewContext } from '../lib'; type Props = { scrollEnabled: boolean; + scrollEventThrottle?: number; } & PropsWithChildren; const PaddingToBottom = IS_SMALL_SIZE_SCREEN ? 30 : 40; -const ScrollableContent: FC = ({ children, scrollEnabled }: Props) => { +const ScrollableContent: FC = ({ + children, + scrollEnabled, + scrollEventThrottle = 300, +}: Props) => { const [containerHeight, setContainerHeight] = useState(null); const [isAreaScrollable, setAreaScrollable] = useState(false); @@ -122,7 +127,7 @@ const ScrollableContent: FC = ({ children, scrollEnabled }: Props) => { showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} keyboardOpeningTime={0} - scrollEventThrottle={300} + scrollEventThrottle={scrollEventThrottle} onScroll={onScroll} overScrollMode="never" alwaysBounceVertical={false} diff --git a/src/shared/ui/SimpleGrid/GridRow.tsx b/src/shared/ui/SimpleGrid/GridRow.tsx new file mode 100644 index 000000000..21c038ace --- /dev/null +++ b/src/shared/ui/SimpleGrid/GridRow.tsx @@ -0,0 +1,48 @@ +import { useRef, useState } from 'react'; +import { LayoutChangeEvent } from 'react-native'; + +import { GridProps } from './types'; +import { calculateMaxHeight } from './utils'; +import { Box, XStack } from '../'; + +function GridRow({ + data, + space, + cellWidth, + renderItem, +}: GridProps) { + const [itemHeight, setItemHeight] = useState<'auto' | number>('auto'); + const heightsMapRef = useRef>(new Map()); + + const onItemLayout = (id: string) => { + return (e: LayoutChangeEvent) => { + const { height } = e.nativeEvent.layout; + const heightsMap = heightsMapRef.current; + + if (!heightsMap.has(id)) { + heightsMap.set(id, height); + } + + if (heightsMap.size === data.length) { + setItemHeight(calculateMaxHeight(heightsMap)); + } + }; + }; + + return ( + + {data.map(item => ( + + {renderItem(item)} + + ))} + + ); +} + +export default GridRow; diff --git a/src/shared/ui/SimpleGrid/SimpleGrid.tsx b/src/shared/ui/SimpleGrid/SimpleGrid.tsx new file mode 100644 index 000000000..ebd46db05 --- /dev/null +++ b/src/shared/ui/SimpleGrid/SimpleGrid.tsx @@ -0,0 +1,55 @@ +import { useMemo, useState } from 'react'; +import { LayoutChangeEvent } from 'react-native'; + +import { isTablet } from 'react-native-device-info'; + +import GridRow from './GridRow'; +import { GridProps } from './types'; +import { calculateItemsPerRow } from './utils'; +import { Box } from '../'; +import { chunkArray } from '../../lib'; + +const TABLET_VIEW_EXTRA_MARGIN = 40; + +function SimpleGrid({ + data, + cellWidth, + space, + + renderItem, +}: GridProps) { + const [width, setWidth] = useState(0); + + const onLayout = (e: LayoutChangeEvent) => { + setWidth(e.nativeEvent.layout.width); + }; + + const extraSpace = space + (isTablet() ? TABLET_VIEW_EXTRA_MARGIN : 0); + const itemsPerRow = calculateItemsPerRow(cellWidth, extraSpace); + + const rowsData = useMemo( + () => chunkArray(data, itemsPerRow), + [data, itemsPerRow], + ); + + return ( + + {rowsData.map((rowData, i) => ( + + ))} + + ); +} + +export default SimpleGrid; diff --git a/src/shared/ui/SimpleGrid/index.ts b/src/shared/ui/SimpleGrid/index.ts new file mode 100644 index 000000000..bc6cb2243 --- /dev/null +++ b/src/shared/ui/SimpleGrid/index.ts @@ -0,0 +1 @@ +export { default as SimpleGrid } from './SimpleGrid'; diff --git a/src/shared/ui/SimpleGrid/types.ts b/src/shared/ui/SimpleGrid/types.ts new file mode 100644 index 000000000..a32dfbac0 --- /dev/null +++ b/src/shared/ui/SimpleGrid/types.ts @@ -0,0 +1,7 @@ +export type GridProps = { + data: Array; + cellWidth: number; + space: number; + + renderItem: (item: TItem) => JSX.Element; +}; diff --git a/src/shared/ui/SimpleGrid/utils.tsx b/src/shared/ui/SimpleGrid/utils.tsx new file mode 100644 index 000000000..b4c62fd08 --- /dev/null +++ b/src/shared/ui/SimpleGrid/utils.tsx @@ -0,0 +1,20 @@ +import { Dimensions } from 'react-native'; + +export const calculateMaxHeight = (heightsMap: Map): number => { + const heightsArray = Array.from(heightsMap).map(([_, height]) => height); + + return Math.max(...heightsArray); +}; + +export const calculateItemsPerRow = ( + cellWidth: number, + extraSpace: number, +): number => { + const windowWidth = Dimensions.get('window').width; + const availableWidth = windowWidth - extraSpace; + const itemTotalWidth = Math.min(cellWidth + extraSpace, availableWidth); + + const itemsPerRow = Math.floor(availableWidth / itemTotalWidth); + + return itemsPerRow; +}; diff --git a/src/shared/ui/Tooltip.tsx b/src/shared/ui/Tooltip.tsx index 6274a848d..221d4b443 100644 --- a/src/shared/ui/Tooltip.tsx +++ b/src/shared/ui/Tooltip.tsx @@ -12,11 +12,13 @@ type TooltipProps = { children: React.ReactNode; markdown?: string; triggerAccessibilityLabel?: string | null; + hitSlop?: number; }; const Tooltip: FC = ({ children, markdown, + hitSlop = 40, accessibilityLabel, triggerAccessibilityLabel, }) => { @@ -29,7 +31,7 @@ const Tooltip: FC = ({ popoverStyle={styles.popover} from={ {children} diff --git a/src/shared/ui/icons/QuestionIcon.tsx b/src/shared/ui/icons/QuestionIcon.tsx new file mode 100644 index 000000000..25d20c769 --- /dev/null +++ b/src/shared/ui/icons/QuestionIcon.tsx @@ -0,0 +1,10 @@ +import Svg, { Path, SvgProps } from 'react-native-svg'; + +export default ({ color, ...props }: SvgProps) => ( + + + +); diff --git a/src/shared/ui/icons/index.tsx b/src/shared/ui/icons/index.tsx index 3a8689c1b..f811ded4c 100644 --- a/src/shared/ui/icons/index.tsx +++ b/src/shared/ui/icons/index.tsx @@ -12,6 +12,7 @@ export { default as AboutIcon } from './About'; export { default as DataIcon } from './Data'; export { default as SurveyIcon } from './Survey'; export { default as CloudLogo } from './CloudLogo'; +export { default as QuestionIcon } from './QuestionIcon'; type IconProps = { color: string; diff --git a/src/shared/ui/survey/CheckBox/CheckBoxActivity.item.test.tsx b/src/shared/ui/survey/CheckBox/CheckBoxActivity.item.test.tsx index 619d12508..b51844b30 100644 --- a/src/shared/ui/survey/CheckBox/CheckBoxActivity.item.test.tsx +++ b/src/shared/ui/survey/CheckBox/CheckBoxActivity.item.test.tsx @@ -46,6 +46,7 @@ describe('Test CheckBoxActivityItem', () => { addTooltip: false, setPalette: false, setAlerts: false, + isGridView: false, }; const checkboxItem = renderer.create( @@ -75,6 +76,7 @@ describe('Test CheckBoxActivityItem', () => { addTooltip: false, setPalette: false, setAlerts: false, + isGridView: false, }; const checkboxItem = renderer.create( diff --git a/src/shared/ui/survey/CheckBox/CheckBoxActivity.item.tsx b/src/shared/ui/survey/CheckBox/CheckBoxActivity.item.tsx index 413b27ed2..416b973d1 100644 --- a/src/shared/ui/survey/CheckBox/CheckBoxActivity.item.tsx +++ b/src/shared/ui/survey/CheckBox/CheckBoxActivity.item.tsx @@ -1,9 +1,10 @@ import { FC, useMemo } from 'react'; import { shuffle } from '@shared/lib'; -import { Box, ScrollView } from '@shared/ui'; +import { ScrollView } from '@shared/ui'; -import CheckBoxItem from './CheckBox.item'; +import CheckBoxGrid from './CheckBoxGrid'; +import CheckBoxList from './CheckBoxList'; import { Item } from './types'; type Props = { @@ -13,23 +14,21 @@ type Props = { addTooltip: boolean; setPalette: boolean; setAlerts: boolean; + isGridView: boolean; }; onChange: (values: Item[] | null) => void; values: Item[]; textReplacer: (markdown: string) => string; }; -const findById = (items: Item[], id: string): Item | undefined => { - return items.find(val => val.id === id); -}; - const CheckBoxActivityItem: FC = ({ config, onChange, values, textReplacer, }) => { - const { options, randomizeOptions, addTooltip, setPalette } = config; + const { options, randomizeOptions, addTooltip, setPalette, isGridView } = + config; const hasImage = useMemo( () => options.some(option => !!option.image), @@ -82,28 +81,21 @@ const CheckBoxActivityItem: FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [randomizeOptions]); + const CheckBoxesView = isGridView ? CheckBoxGrid : CheckBoxList; + return ( - {mutatedItems.map((item, index) => { - return ( - - onItemValueChanged(item)} - value={!!findById(values, item.id)} - textReplacer={textReplacer} - position={index} - /> - - ); - })} + onItemValueChanged(item)} + textReplacer={textReplacer} + mb={16} + /> ); }; diff --git a/src/shared/ui/survey/CheckBox/CheckBoxCard.tsx b/src/shared/ui/survey/CheckBox/CheckBoxCard.tsx new file mode 100644 index 000000000..85614cd48 --- /dev/null +++ b/src/shared/ui/survey/CheckBox/CheckBoxCard.tsx @@ -0,0 +1,147 @@ +import { useMemo } from 'react'; +import { Platform, StyleSheet } from 'react-native'; + +import { IS_ANDROID, colors, invertColor } from '@app/shared/lib'; + +import { Box, BoxProps, CheckBox, QuestionIcon, Tooltip } from '../..'; +import OptionCard from '../../OptionCard'; + +import { Item } from './'; + +type CheckBoxProps = { + setPalette: boolean; + imageContainerVisible: boolean; + tooltipContainerVisible: boolean; + tooltipAvailable: boolean; + onPress: () => void; + value: boolean; + selected: boolean; + textReplacer: (markdown: string) => string; +} & Omit; + +type Props = CheckBoxProps & Omit; + +function CheckBoxCard({ + selected, + + imageContainerVisible, + image, + text, + + tooltip, + tooltipContainerVisible, + setPalette, + color, + + accessibilityLabel, + + onPress, + textReplacer, + + ...styledProps +}: Props) { + const shouldRenderTooltip = tooltipContainerVisible && !!tooltip; + + const name = useMemo(() => textReplacer(text), [textReplacer, text]); + const tooltipText = useMemo( + () => textReplacer(tooltip ?? ''), + [textReplacer, tooltip], + ); + + const hasColor = color && setPalette; + + const defaultBGColor = selected ? colors.lighterGrey6 : colors.white; + const bgColor = hasColor ? color : defaultBGColor; + + const textColor = color + ? invertColor(color, { dark: colors.white, light: colors.black }) + : colors.onSurface; + + const tooltipColor = color + ? invertColor(color, { + dark: colors.darkOnSurface, + light: colors.onSurface, + }) + : colors.darkerGrey4; + + const invertedCheckboxColor = color + ? invertColor(color, { + dark: colors.darkOnSurface, + light: colors.onSurface, + }) + : colors.lighterGrey6; + + const borderColor = selected ? colors.blue3 : colors.lighterGrey7; + + return ( + ( + + console.log(IS_ANDROID, e.nativeEvent.layout)} + /> + + )} + renderRightIcon={() => + shouldRenderTooltip ? ( + + + + + + ) : null + } + {...styledProps} + > + {name} + + ); +} + +const styles = StyleSheet.create({ + checkboxIOS: { + width: 21, + height: 21, + position: 'absolute', + right: 0, + }, + checkboxAndroid: { + width: 21, + height: 21, + position: 'absolute', + right: 5, + transform: [{ scaleX: 1.2 }, { scaleY: 1.2 }], + }, +}); + +export default CheckBoxCard; diff --git a/src/shared/ui/survey/CheckBox/CheckBoxGrid.tsx b/src/shared/ui/survey/CheckBox/CheckBoxGrid.tsx new file mode 100644 index 000000000..2d5c69a92 --- /dev/null +++ b/src/shared/ui/survey/CheckBox/CheckBoxGrid.tsx @@ -0,0 +1,52 @@ +import CheckBoxCard from './CheckBoxCard'; +import { CheckboxItemProps } from './types'; +import { findById } from './utils'; +import { Box } from '../..'; +import { SimpleGrid } from '../../SimpleGrid'; + +function CheckBoxGrid({ + value, + options, + + addTooltip, + setPalette, + hasImage, + hasTooltip, + + onChange, + textReplacer, + + ...styledProps +}: CheckboxItemProps) { + return ( + + ( + + onChange(item)} + value={!!findById(value, item.id)} + textReplacer={textReplacer} + flex={1} + /> + + )} + /> + + ); +} + +export default CheckBoxGrid; diff --git a/src/shared/ui/survey/CheckBox/CheckBoxList.tsx b/src/shared/ui/survey/CheckBox/CheckBoxList.tsx new file mode 100644 index 000000000..cd1eee9ea --- /dev/null +++ b/src/shared/ui/survey/CheckBox/CheckBoxList.tsx @@ -0,0 +1,46 @@ +import CheckBoxItem from './CheckBox.item'; +import { CheckboxItemProps } from './types'; +import { findById } from './utils'; +import { Box } from '../..'; + +function CheckBoxList({ + value, + options, + + addTooltip, + setPalette, + hasImage, + hasTooltip, + + onChange, + textReplacer, + + ...styledProps +}: CheckboxItemProps) { + return ( + + {options.map((item, index) => { + return ( + + onChange(item)} + value={!!findById(value, item.id)} + textReplacer={textReplacer} + position={index} + /> + + ); + })} + + ); +} + +export default CheckBoxList; diff --git a/src/shared/ui/survey/CheckBox/types.ts b/src/shared/ui/survey/CheckBox/types.ts index 1e415269e..6b73c7167 100644 --- a/src/shared/ui/survey/CheckBox/types.ts +++ b/src/shared/ui/survey/CheckBox/types.ts @@ -1,5 +1,7 @@ import { ImageUrl } from '@app/shared/lib'; +import { BoxProps } from '../..'; + export type Item = { id: string; text: string; @@ -11,3 +13,16 @@ export type Item = { value: number; isNoneOption?: boolean; }; + +export type CheckboxItemProps = { + value: Array; + options: Array; + + addTooltip: boolean; + setPalette: boolean; + hasImage: boolean; + hasTooltip: boolean; + + onChange: (newValue: Item) => void; + textReplacer: (markdown: string) => string; +} & BoxProps; diff --git a/src/shared/ui/survey/CheckBox/utils.ts b/src/shared/ui/survey/CheckBox/utils.ts new file mode 100644 index 000000000..d4b19a450 --- /dev/null +++ b/src/shared/ui/survey/CheckBox/utils.ts @@ -0,0 +1,5 @@ +import { Item } from './types'; + +export const findById = (items: Item[], id: string): Item | undefined => { + return items.find(val => val.id === id); +}; diff --git a/src/shared/ui/survey/RadioActivityItem/RadioActivityItem.tsx b/src/shared/ui/survey/RadioActivityItem/RadioActivityItem.tsx index c3c842495..7028e6e1d 100644 --- a/src/shared/ui/survey/RadioActivityItem/RadioActivityItem.tsx +++ b/src/shared/ui/survey/RadioActivityItem/RadioActivityItem.tsx @@ -1,10 +1,11 @@ import React, { FC, useMemo, useState } from 'react'; import { AccessibilityProps } from 'react-native'; -import { shuffle, colors } from '@shared/lib'; -import { YStack, RadioGroup, Box, useOnUndo } from '@shared/ui'; +import { shuffle } from '@shared/lib'; +import { useOnUndo } from '@shared/ui'; -import RadioItem from './RadioItem'; +import RadioGrid from './RadioGrid'; +import RadioList from './RadioList'; import RadioOption from './types'; type RadioActivityItemProps = { @@ -13,6 +14,7 @@ type RadioActivityItemProps = { setPalette: boolean; addTooltip: boolean; randomizeOptions: boolean; + isGridView: boolean; }; onChange: (value: RadioOption) => void; initialValue?: RadioOption; @@ -25,7 +27,8 @@ const RadioActivityItem: FC = ({ initialValue, textReplacer, }) => { - const { options, randomizeOptions, addTooltip, setPalette } = config; + const { options, randomizeOptions, addTooltip, setPalette, isGridView } = + config; const [radioValueId, setRadioValueId] = useState(initialValue?.id ?? null); const selectedOptionIndex: number | null = useMemo(() => { @@ -62,34 +65,21 @@ const RadioActivityItem: FC = ({ onChange(selectedOption!); }; + const RadiosView = isGridView ? RadioGrid : RadioList; + return ( - - - {optionsList.map(option => ( - onValueChange(option.id)} - > - - - ))} - - + ); }; diff --git a/src/shared/ui/survey/RadioActivityItem/RadioCard.tsx b/src/shared/ui/survey/RadioActivityItem/RadioCard.tsx new file mode 100644 index 000000000..53bd252fc --- /dev/null +++ b/src/shared/ui/survey/RadioActivityItem/RadioCard.tsx @@ -0,0 +1,122 @@ +import { useMemo } from 'react'; +import { AccessibilityProps } from 'react-native'; + +import { colors, invertColor } from '@app/shared/lib'; + +import RadioOption from './types'; +import { Box, BoxProps, QuestionIcon, RadioGroup, Tooltip } from '../..'; +import OptionCard from '../../OptionCard'; + +type RadioLabelProps = { + option: RadioOption; + selected: boolean; + addTooltip: boolean; + setPalette: boolean; + imageContainerVisible: boolean; + tooltipContainerVisible: boolean; + textReplacer: (markdown: string) => string; +}; + +type HandlerProps = { + onPress: () => void; +}; + +type Props = RadioLabelProps & AccessibilityProps & HandlerProps & BoxProps; + +function RadioCard({ + selected, + option: { isHidden, id, text, color, image, tooltip, value }, + addTooltip, + imageContainerVisible, + tooltipContainerVisible, + setPalette, + accessibilityLabel, + textReplacer, + onPress, + + ...styledProps +}: Props) { + const shouldRenderTooltip = + addTooltip && tooltipContainerVisible && !!tooltip; + + const name = useMemo(() => textReplacer(text), [textReplacer, text]); + const tooltipText = useMemo( + () => textReplacer(tooltip ?? ''), + [textReplacer, tooltip], + ); + + const hasColor = color && setPalette; + + const defaultBGColor = selected ? colors.lighterGrey6 : colors.white; + const bgColor = hasColor ? color : defaultBGColor; + + const textColor = color + ? invertColor(color, { dark: colors.white, light: colors.black }) + : colors.onSurface; + + const tooltipColor = color + ? invertColor(color, { + dark: colors.darkOnSurface, + light: colors.onSurface, + }) + : colors.darkerGrey4; + + const invertedRadioColor = color + ? invertColor(color, { + dark: colors.darkOnSurface, + light: colors.onSurface, + }) + : colors.lighterGrey6; + + const defaultRadioColor = selected ? colors.blue3 : colors.outlineGrey; + const radioColor = hasColor ? invertedRadioColor : defaultRadioColor; + const borderColor = selected ? colors.blue3 : colors.lighterGrey7; + + if (isHidden) { + return null; + } + + return ( + ( + + + + + + )} + renderRightIcon={() => + shouldRenderTooltip ? ( + + + + + + ) : null + } + {...styledProps} + > + {name} + + ); +} + +export default RadioCard; diff --git a/src/shared/ui/survey/RadioActivityItem/RadioGrid.tsx b/src/shared/ui/survey/RadioActivityItem/RadioGrid.tsx new file mode 100644 index 000000000..5d7e01136 --- /dev/null +++ b/src/shared/ui/survey/RadioActivityItem/RadioGrid.tsx @@ -0,0 +1,52 @@ +import { RadioGroup } from '@tamagui/radio-group'; + +import RadioCard from './RadioCard'; +import { RadioItemProps } from './types'; +import { SimpleGrid } from '../../SimpleGrid'; + +function RadioGrid({ + value, + options, + + addTooltip, + setPalette, + hasImage, + hasTooltip, + + onChange, + textReplacer, + + ...styledProps +}: RadioItemProps) { + return ( + + ( + onChange(option.id)} + selected={value === option.id} + flex={1} + /> + )} + /> + + ); +} + +export default RadioGrid; diff --git a/src/shared/ui/survey/RadioActivityItem/RadioList.tsx b/src/shared/ui/survey/RadioActivityItem/RadioList.tsx new file mode 100644 index 000000000..b2a799683 --- /dev/null +++ b/src/shared/ui/survey/RadioActivityItem/RadioList.tsx @@ -0,0 +1,55 @@ +import { RadioGroup } from '@tamagui/radio-group'; +import { YStack } from '@tamagui/stacks'; + +import { colors } from '@app/shared/lib'; + +import RadioItem from './RadioItem'; +import { RadioItemProps } from './types'; +import { Box } from '../..'; + +function RadioList({ + value, + options, + + addTooltip, + setPalette, + hasImage, + hasTooltip, + + onChange, + textReplacer, + + ...styledProps +}: RadioItemProps) { + return ( + + + {options.map(option => ( + onChange(option.id)} + > + + + ))} + + + ); +} + +export default RadioList; diff --git a/src/shared/ui/survey/RadioActivityItem/types.ts b/src/shared/ui/survey/RadioActivityItem/types.ts index 77d4ac3fe..ac7dd2844 100644 --- a/src/shared/ui/survey/RadioActivityItem/types.ts +++ b/src/shared/ui/survey/RadioActivityItem/types.ts @@ -1,5 +1,7 @@ import { ImageUrl } from '@app/shared/lib'; +import { BoxProps } from '../..'; + type RadioOption = { id: string; text: string; @@ -12,3 +14,16 @@ type RadioOption = { }; export default RadioOption; + +export type RadioItemProps = { + value: string | null; + options: Array; + + addTooltip: boolean; + setPalette: boolean; + hasImage: boolean; + hasTooltip: boolean; + + onChange: (newValue: string) => void; + textReplacer: (markdown: string) => string; +} & BoxProps; diff --git a/src/widgets/survey/model/tests/mappers.input.mock.ts b/src/widgets/survey/model/tests/mappers.input.mock.ts index 593b66b37..2d3349786 100644 --- a/src/widgets/survey/model/tests/mappers.input.mock.ts +++ b/src/widgets/survey/model/tests/mappers.input.mock.ts @@ -56,6 +56,7 @@ export const radioInput: PipelineItem = { setAlerts: true, setPalette: false, autoAdvance: true, + isGridView: false, options: [ { alert: { @@ -188,6 +189,7 @@ export const checkboxInput: PipelineItem = { setAlerts: true, addTooltip: false, setPalette: false, + isGridView: false, options: [ { id: '1dd850ae-03f8-48c2-ba16-996b92d33475',