diff --git a/.eslintrc.js b/.eslintrc.js index f45636d57..4c79c9337 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -55,6 +55,7 @@ module.exports = { ], 'prefer-destructuring': ['error', { array: false }], 'prefer-object-spread': 'error', + 'no-console': 'error', 'react/function-component-definition': [ 'error', diff --git a/assets/images/index.ts b/assets/images/index.ts index fd525776a..ef188ebca 100644 --- a/assets/images/index.ts +++ b/assets/images/index.ts @@ -64,9 +64,10 @@ import StarCircleIconGrey from './star-circle-icon-grey.svg' import StarIconGreyFilled from './star-icon-grey-filled.svg' import StarIconGrey from './star-icon-grey.svg' import StarIconWhite from './star-icon-white.svg' +import ThumbsDownIcon from './thumbs-down.svg' +import ThumbsUpIcon from './thumbs-up.svg' import TrashIconWhite from './trash-bin-icon-white.svg' import TrashIcon from './trash-bin-icon.svg' -import TrophyIcon from './trophy-icon.svg' import VolumeUpCircleIcon from './volume-up-circle-icon.svg' export { @@ -138,6 +139,7 @@ export { StarIconWhite, TrashIcon, TrashIconWhite, - TrophyIcon, VolumeUpCircleIcon, + ThumbsUpIcon, + ThumbsDownIcon, } diff --git a/assets/images/thumbs-down.svg b/assets/images/thumbs-down.svg new file mode 100644 index 000000000..1b00be3c4 --- /dev/null +++ b/assets/images/thumbs-down.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/thumbs-up.svg b/assets/images/thumbs-up.svg new file mode 100644 index 000000000..10785c304 --- /dev/null +++ b/assets/images/thumbs-up.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/images/trophy-icon.svg b/assets/images/trophy-icon.svg deleted file mode 100644 index e13fb4b65..000000000 --- a/assets/images/trophy-icon.svg +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/release-notes/unreleased/LUN-407-add-evaluation-of-levels.yml b/release-notes/unreleased/LUN-407-add-evaluation-of-levels.yml new file mode 100644 index 000000000..2fb37e16c --- /dev/null +++ b/release-notes/unreleased/LUN-407-add-evaluation-of-levels.yml @@ -0,0 +1,6 @@ +issue_key: LUN-407 +show_in_stores: true +platforms: + - android + - ios +de: Zeige Feedback zu bereits abgeschlossenen Leveln diff --git a/src/components/AudioPlayer.tsx b/src/components/AudioPlayer.tsx index 120cedbe2..99f987c4a 100644 --- a/src/components/AudioPlayer.tsx +++ b/src/components/AudioPlayer.tsx @@ -54,8 +54,10 @@ const AudioPlayer = ({ document, disabled, submittedAlternative }: AudioPlayerPr } }) .catch(async (error: TtsError) => { + /* eslint-disable-next-line no-console */ console.error(`Tts-Error: ${error.code}`) if (error.code === 'no_engine') { + /* eslint-disable-next-line no-console */ await Tts.requestInstallEngine().catch(e => console.error('Failed to install tts engine: ', e)) } }) diff --git a/src/components/ErrorMessage.tsx b/src/components/ErrorMessage.tsx index dffe9504c..6c1cbc8d3 100644 --- a/src/components/ErrorMessage.tsx +++ b/src/components/ErrorMessage.tsx @@ -65,7 +65,7 @@ const ErrorMessage = ({ error, refresh, contained }: ErrorMessageProps): JSX.Ele return ( - + {error.message === NetworkError && ( diff --git a/src/components/FeedbackBadge.tsx b/src/components/FeedbackBadge.tsx new file mode 100644 index 000000000..518137e4c --- /dev/null +++ b/src/components/FeedbackBadge.tsx @@ -0,0 +1,58 @@ +import React, { ReactElement } from 'react' +import { heightPercentageToDP as hp, widthPercentageToDP as wp } from 'react-native-responsive-screen' +import styled from 'styled-components/native' + +import { ThumbsDownIcon, ThumbsUpIcon } from '../../assets/images' +import { EXERCISE_FEEDBACK } from '../constants/data' +import { getLabels } from '../services/helpers' +import { ContentSecondaryLight } from './text/Content' + +const BadgeContainer = styled.View` + display: flex; + flex-flow: row nowrap; + padding: ${props => props.theme.spacings.xxs} ${props => props.theme.spacings.sm}; + background-color: ${props => props.theme.colors.lightGreyBackground}; + width: 100%; +` +const BadgeText = styled(ContentSecondaryLight)` + font-style: italic; + margin-left: ${props => props.theme.spacings.xs}; + align-self: center; +` + +const BadgeIcon = styled.View` + height: ${hp('3.5%')}px; + max-height: ${wp('5%')}px; +` + +interface FeedbackBadgeProps { + feedback: EXERCISE_FEEDBACK +} + +const FeedbackBadge = ({ feedback }: FeedbackBadgeProps): ReactElement | null => { + const { positive, negative } = { ...getLabels().exercises.feedback } + if (feedback === EXERCISE_FEEDBACK.POSITIVE) { + return ( + + + + + {positive} + + ) + } + + if (feedback === EXERCISE_FEEDBACK.NEGATIVE) { + return ( + + + + + {negative} + + ) + } + + return null +} +export default FeedbackBadge diff --git a/src/components/ListItem.tsx b/src/components/ListItem.tsx index 26f50cf04..d47a6d5ad 100644 --- a/src/components/ListItem.tsx +++ b/src/components/ListItem.tsx @@ -4,28 +4,59 @@ import { heightPercentageToDP as hp, widthPercentageToDP as wp } from 'react-nat import styled, { useTheme } from 'styled-components/native' import { ChevronRight } from '../../assets/images' +import { EXERCISE_FEEDBACK } from '../constants/data' +import FeedbackBadge from './FeedbackBadge' import { ContentSecondaryLight } from './text/Content' export const GenericListItemContainer = styled.Pressable` margin-bottom: ${props => props.theme.spacings.xxs}; - align-self: center; flex-direction: row; justify-content: space-between; align-items: center; border-radius: 2px; ` - -const Container = styled(GenericListItemContainer)<{ pressed: boolean; disabled: boolean }>` +const Container = styled(GenericListItemContainer)<{ + pressed: boolean + disabled: boolean + feedback: EXERCISE_FEEDBACK +}>` min-height: ${hp('12%')}px; + justify-content: center; + flex-direction: column; + border-top-width: 1px; + border-top-color: ${prop => (prop.pressed ? prop.theme.colors.primary : prop.theme.colors.disabled)}; + border-right-width: 1px; + border-right-color: ${prop => (prop.pressed ? prop.theme.colors.primary : prop.theme.colors.disabled)}; + border-bottom-width: 1px; + border-bottom-color: ${prop => (prop.pressed ? prop.theme.colors.primary : prop.theme.colors.disabled)}; + border-left-color: ${props => { + if (props.feedback === EXERCISE_FEEDBACK.POSITIVE) { + return props.theme.colors.correct + } + if (props.feedback === EXERCISE_FEEDBACK.NEGATIVE) { + return props.theme.colors.incorrect + } + if (props.pressed) { + return props.theme.colors.primary + } + return props.theme.colors.disabled + }}; + border-left-radius: 0; + border-left-width: ${props => (props.feedback !== EXERCISE_FEEDBACK.NONE ? '4px' : '1px')}; background-color: ${prop => { if (prop.disabled) { return prop.theme.colors.disabled } return prop.pressed ? prop.theme.colors.primary : prop.theme.colors.backgroundAccent }}; - border: 1px solid ${prop => (prop.pressed ? prop.theme.colors.primary : prop.theme.colors.disabled)}; +` + +const ContentContainer = styled.View<{ pressed: boolean; disabled: boolean }>` + display: flex; + flex-direction: row; padding: ${props => `${props.theme.spacings.sm} ${props.theme.spacings.xs} ${props.theme.spacings.sm} ${props.theme.spacings.sm}`}; + align-items: center; ` const Title = styled.Text<{ pressed: boolean }>` @@ -87,6 +118,7 @@ interface ListItemProps { hideRightChildren?: boolean arrowDisabled?: boolean disabled?: boolean + feedback?: EXERCISE_FEEDBACK } const ListItem = ({ @@ -100,6 +132,7 @@ const ListItem = ({ hideRightChildren = false, arrowDisabled = false, disabled = false, + feedback = EXERCISE_FEEDBACK.NONE, }: ListItemProps): ReactElement => { const [pressInY, setPressInY] = useState(null) const [pressed, setPressed] = useState(false) @@ -160,17 +193,21 @@ const ListItem = ({ onLongPress={() => updatePressed(true)} pressed={pressed} delayLongPress={200} + feedback={feedback} testID='list-item'> - {iconToRender} - - {titleToRender} - - {badgeLabel && {badgeLabel}} - {description && description.length > 0 && {description}} - - {children} - - {rightChildrenToRender} + + + {iconToRender} + + {titleToRender} + + {badgeLabel && {badgeLabel}} + {description && description.length > 0 && {description}} + + {children} + + {rightChildrenToRender} + ) } diff --git a/src/components/NotAuthorisedView.tsx b/src/components/NotAuthorisedView.tsx index 78ee673b4..08230a12f 100644 --- a/src/components/NotAuthorisedView.tsx +++ b/src/components/NotAuthorisedView.tsx @@ -6,6 +6,7 @@ import { BUTTONS_THEME } from '../constants/data' import { getLabels } from '../services/helpers' import Button from './Button' import { ContentSecondary } from './text/Content' +import { reportError } from '../services/sentry' const Container = styled.View` display: flex; @@ -25,7 +26,7 @@ interface Props { const NotAuthorisedView = ({ setVisible }: Props): ReactElement => { const openSettings = () => { - Linking.openSettings().catch(() => console.error('Unable to open Settings')) + Linking.openSettings().catch(reportError) } return ( diff --git a/src/components/Trophy.tsx b/src/components/Trophy.tsx deleted file mode 100644 index 6b6b0b84e..000000000 --- a/src/components/Trophy.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React, { ReactElement } from 'react' -import { heightPercentageToDP as hp, widthPercentageToDP as wp } from 'react-native-responsive-screen' -import styled from 'styled-components/native' - -import { TrophyIcon } from '../../assets/images' - -const TrophyContainer = styled.View` - margin-top: ${props => props.theme.spacings.xs}; - display: flex; - flex-direction: row; -` - -const TrophyIconStyled = styled(TrophyIcon)` - margin-right: ${props => props.theme.spacings.xxs}; -` - -interface PropsType { - level: number -} - -const Trophy = ({ level }: PropsType): ReactElement => { - const trophies = [] - for (let i = 0; i < level; i += 1) { - trophies.push() - } - return {trophies} -} - -export default Trophy diff --git a/src/components/__tests__/FeedbackBadge.spec.tsx b/src/components/__tests__/FeedbackBadge.spec.tsx new file mode 100644 index 000000000..e978ad025 --- /dev/null +++ b/src/components/__tests__/FeedbackBadge.spec.tsx @@ -0,0 +1,38 @@ +import { waitFor } from '@testing-library/react-native' +import React from 'react' + +import { SCORE_THRESHOLD_POSITIVE_FEEDBACK, EXERCISE_FEEDBACK } from '../../constants/data' +import { getLabels } from '../../services/helpers' +import render from '../../testing/render' +import FeedbackBadge from '../FeedbackBadge' + +describe('FeedbackBadge', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + const renderFeedbackBadge = (feedback: EXERCISE_FEEDBACK) => render() + + it('should not show badge for no feedback', async () => { + const { queryByTestId } = renderFeedbackBadge(EXERCISE_FEEDBACK.NONE) + + await waitFor(() => expect(queryByTestId('positive-badge')).toBeNull()) + await waitFor(() => expect(queryByTestId('negative-badge')).toBeNull()) + }) + + it('should show badge for positive feedback', async () => { + const { queryByText, queryByTestId } = renderFeedbackBadge(EXERCISE_FEEDBACK.POSITIVE) + + await waitFor(() => expect(queryByTestId('negative-badge')).toBeNull()) + await waitFor(() => expect(queryByTestId('positive-badge')).not.toBeNull()) + await waitFor(() => expect(queryByText(getLabels().exercises.feedback.positive)).not.toBeNull()) + }) + + it('should show badge for negative feedback', async () => { + const { queryByText, queryByTestId } = renderFeedbackBadge(EXERCISE_FEEDBACK.NEGATIVE) + + await waitFor(() => expect(queryByTestId('positive-badge')).toBeNull()) + await waitFor(() => expect(queryByTestId('negative-badge')).not.toBeNull()) + await waitFor(() => expect(queryByText(getLabels().exercises.feedback.negative)).not.toBeNull()) + }) +}) diff --git a/src/components/__tests__/ListItem.spec.tsx b/src/components/__tests__/ListItem.spec.tsx index 12eff6ded..7210a083f 100644 --- a/src/components/__tests__/ListItem.spec.tsx +++ b/src/components/__tests__/ListItem.spec.tsx @@ -5,6 +5,7 @@ import { COLORS } from '../../constants/theme/colors' import render from '../../testing/render' import ListItem from '../ListItem' +jest.mock('@react-navigation/native') describe('ListItem', () => { const onPress = jest.fn() const description = 'Wörter' diff --git a/src/components/__tests__/Trophy.spec.tsx b/src/components/__tests__/Trophy.spec.tsx deleted file mode 100644 index 16d5a67ad..000000000 --- a/src/components/__tests__/Trophy.spec.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react' - -import renderWithTheme from '../../testing/render' -import Trophy from '../Trophy' - -describe('Trophy', () => { - it('should show no trophy when level is 0', () => { - const { queryByTestId } = renderWithTheme() - expect(queryByTestId('trophy-0')).toBeNull() - }) - - it('should show one trophy when level is 1', () => { - const { queryByTestId } = renderWithTheme() - expect(queryByTestId('trophy-0')).not.toBeNull() - expect(queryByTestId('trophy-1')).toBeNull() - }) - - it('should show three trophies when level is 3', () => { - const { queryByTestId } = renderWithTheme() - expect(queryByTestId('trophy-0')).not.toBeNull() - expect(queryByTestId('trophy-1')).not.toBeNull() - expect(queryByTestId('trophy-2')).not.toBeNull() - expect(queryByTestId('trophy-3')).toBeNull() - }) -}) diff --git a/src/constants/data.ts b/src/constants/data.ts index 25b56e315..38a449d02 100644 --- a/src/constants/data.ts +++ b/src/constants/data.ts @@ -166,3 +166,9 @@ export const numberOfMaxRetries = 3 export const SCORE_THRESHOLD_POSITIVE_FEEDBACK = 4 export const SCORE_THRESHOLD_UNLOCK = 2 + +export const enum EXERCISE_FEEDBACK { + POSITIVE, + NONE, + NEGATIVE, +} diff --git a/src/constants/labels.json b/src/constants/labels.json index 65e583140..13454d225 100644 --- a/src/constants/labels.json +++ b/src/constants/labels.json @@ -98,6 +98,10 @@ "cancelModal": { "cancelAsk": "Möchtest du diese Übung wirklich beenden?", "cancel": "Beenden" + }, + "feedback": { + "positive": "Weiter so, das war super!", + "negative": "Das geht besser. Probier es nochmal." } }, "results": { diff --git a/src/constants/theme/colors.ts b/src/constants/theme/colors.ts index 4c38e5e28..d0e6f0b7d 100644 --- a/src/constants/theme/colors.ts +++ b/src/constants/theme/colors.ts @@ -22,7 +22,7 @@ export const COLORS = { articleFeminine: '#faa7a7', articleMasculine: '#8cc8f3', link: 'blue', - networkErrorBackground: '#e0e4ed', + lightGreyBackground: '#e0e4ed', } export type Color = typeof COLORS[keyof typeof COLORS] diff --git a/src/routes/__tests__/VocabularyListScreen.spec.tsx b/src/routes/__tests__/VocabularyListScreen.spec.tsx index 69e03752e..4f5a9e014 100644 --- a/src/routes/__tests__/VocabularyListScreen.spec.tsx +++ b/src/routes/__tests__/VocabularyListScreen.spec.tsx @@ -24,6 +24,8 @@ jest.mock('../../components/AudioPlayer', () => { return () => AudioPlayer }) +jest.mock('../../components/FeedbackBadge', () => () => null) + describe('VocabularyListScreen', () => { const documents = new DocumentBuilder(2).build() const route: RouteProp = { diff --git a/src/routes/exercises/ExercisesScreen.tsx b/src/routes/exercises/ExercisesScreen.tsx index aac37c605..9f5a0f09b 100644 --- a/src/routes/exercises/ExercisesScreen.tsx +++ b/src/routes/exercises/ExercisesScreen.tsx @@ -1,6 +1,6 @@ -import { CommonActions, RouteProp, useFocusEffect } from '@react-navigation/native' +import { CommonActions, RouteProp, useIsFocused, useFocusEffect } from '@react-navigation/native' import { StackNavigationProp } from '@react-navigation/stack' -import React, { useCallback, useState } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { FlatList } from 'react-native' import { widthPercentageToDP as wp } from 'react-native-responsive-screen' import styled from 'styled-components/native' @@ -10,11 +10,12 @@ import Modal from '../../components/Modal' import RouteWrapper from '../../components/RouteWrapper' import ServerResponseHandler from '../../components/ServerResponseHandler' import Title from '../../components/Title' -import Trophy from '../../components/Trophy' import { ContentTextBold, ContentTextLight } from '../../components/text/Content' -import { Exercise, EXERCISES } from '../../constants/data' +import { Exercise, EXERCISES, SCORE_THRESHOLD_POSITIVE_FEEDBACK, EXERCISE_FEEDBACK } from '../../constants/data' +import { useLoadAsync } from '../../hooks/useLoadAsync' import useLoadDocuments from '../../hooks/useLoadDocuments' import { RoutesParams } from '../../navigation/NavigationTypes' +import { getExerciseProgress } from '../../services/AsyncStorage' import { getLabels, getDoneExercises, wordsDescription } from '../../services/helpers' import { reportError } from '../../services/sentry' import LockingLane from './components/LockingLane' @@ -22,6 +23,7 @@ import LockingLane from './components/LockingLane' const Container = styled.View` display: flex; flex-direction: row; + align-items: center; ` const ListItemResizer = styled.View` @@ -42,7 +44,10 @@ const ExercisesScreen = ({ route, navigation }: ExercisesScreenProps): JSX.Eleme const { discipline, disciplineTitle, disciplineId } = route.params const [isModalVisible, setIsModalVisible] = useState(false) const [nextExercise, setNextExercise] = useState(EXERCISES[0]) - + const [feedback, setFeedback] = useState([]) + const [isFeedbackSet, setIsFeedbackSet] = useState(false) + const { data: scores, loading: loadingFeedback, refresh: refreshFeedback } = useLoadAsync(getExerciseProgress, null) + const isFocused = useIsFocused() const { data: documents, error, @@ -53,6 +58,21 @@ const ExercisesScreen = ({ route, navigation }: ExercisesScreenProps): JSX.Eleme apiKey: discipline.apiKey, }) + useEffect(() => { + if (!loadingFeedback && !isFeedbackSet) { + const exerciseScores = scores?.[disciplineId] ?? {} + const updatedFeedback: EXERCISE_FEEDBACK[] = Object.values(exerciseScores).map(score => { + if (!score) { + return EXERCISE_FEEDBACK.NONE + } + return score > SCORE_THRESHOLD_POSITIVE_FEEDBACK ? EXERCISE_FEEDBACK.POSITIVE : EXERCISE_FEEDBACK.NEGATIVE + }) + updatedFeedback[0] = EXERCISE_FEEDBACK.NONE + setFeedback(updatedFeedback) + setIsFeedbackSet(true) + } + }, [loadingFeedback, isFeedbackSet, disciplineId, scores]) + useFocusEffect( useCallback(() => { getDoneExercises(disciplineId) @@ -61,6 +81,13 @@ const ExercisesScreen = ({ route, navigation }: ExercisesScreenProps): JSX.Eleme }, [disciplineId]) ) + useEffect(() => { + if (isFocused) { + refreshFeedback() + setIsFeedbackSet(false) + } + }, [isFocused, refreshFeedback]) + const handleNavigation = (item: Exercise): void => { if (nextExercise && item.level > nextExercise.level) { setIsModalVisible(true) @@ -90,9 +117,9 @@ const ExercisesScreen = ({ route, navigation }: ExercisesScreenProps): JSX.Eleme title={item.title} description={item.description} onPress={() => handleNavigation(item)} - arrowDisabled={nextExercise === null || item.level > nextExercise.level}> - - + arrowDisabled={nextExercise === null || item.level > nextExercise.level} + feedback={feedback[item.level] ?? EXERCISE_FEEDBACK.NONE} + /> ) diff --git a/src/routes/exercises/__tests__/ExercisesScreen.spec.tsx b/src/routes/exercises/__tests__/ExercisesScreen.spec.tsx index 68d076575..5056a64af 100644 --- a/src/routes/exercises/__tests__/ExercisesScreen.spec.tsx +++ b/src/routes/exercises/__tests__/ExercisesScreen.spec.tsx @@ -1,16 +1,17 @@ import RNAsyncStorage from '@react-native-async-storage/async-storage' import { RouteProp } from '@react-navigation/native' -import { fireEvent } from '@testing-library/react-native' +import { fireEvent, waitFor } from '@testing-library/react-native' import { mocked } from 'jest-mock' import React from 'react' -import { EXERCISES } from '../../../constants/data' +import { EXERCISES, SCORE_THRESHOLD_POSITIVE_FEEDBACK } from '../../../constants/data' import useLoadDocuments from '../../../hooks/useLoadDocuments' import { RoutesParams } from '../../../navigation/NavigationTypes' import DocumentBuilder from '../../../testing/DocumentBuilder' import createNavigationMock from '../../../testing/createNavigationPropMock' import { getReturnOf } from '../../../testing/helper' import { mockDisciplines } from '../../../testing/mockDiscipline' +import { mockUseLoadAsyncWithData } from '../../../testing/mockUseLoadFromEndpoint' import render from '../../../testing/render' import ExercisesScreen from '../ExercisesScreen' @@ -37,6 +38,13 @@ describe('ExercisesScreen', () => { }, } + mockUseLoadAsyncWithData({ + [route.params.disciplineId]: { + '0': SCORE_THRESHOLD_POSITIVE_FEEDBACK - 1, + '1': SCORE_THRESHOLD_POSITIVE_FEEDBACK + 1, + }, + }) + it('should render correctly', () => { const { getAllByText } = render() EXERCISES.forEach(exercise => { @@ -65,4 +73,14 @@ describe('ExercisesScreen', () => { documents, }) }) + + it('should show feedback badge for done levels', async () => { + const { queryByTestId } = render() + await waitFor(() => expect(queryByTestId('positive-badge')).not.toBeNull()) + }) + + it('should not show feedback badge for wordlist level', async () => { + const { queryByTestId } = render() + await waitFor(() => expect(queryByTestId('negative-badge')).toBeNull()) + }) })