Skip to content

Commit

Permalink
Merge pull request #308 from digitalfabrik/LUN-131-logic-for-unlockin…
Browse files Browse the repository at this point in the history
…g-exercises

LUN-131: Logic for unlocking exercises
  • Loading branch information
ztefanie authored Jun 1, 2022
2 parents 45dabb8 + 579366e commit 58724c8
Show file tree
Hide file tree
Showing 12 changed files with 151 additions and 65 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
issue_key: LUN-131
show_in_stores: true
platforms:
- android
- ios
de: Freischaltungsmechanismus für Übungen
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
issue_key: LUN-131
show_in_stores: true
platforms:
- android
- ios
de: Fortschrittsanzeige für Übungen
4 changes: 3 additions & 1 deletion src/navigation/NavigationTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ export type RoutesParams = {
WordChoiceExercise: ExerciseParams
ArticleChoiceExercise: ExerciseParams
WriteExercise: ExerciseParams
ExerciseFinished: ResultParams
ExerciseFinished: ResultParams & {
unlockedNextExercise: boolean
}
Result: ResultParams
ResultDetail: ResultParams & {
resultType: Result
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@ import createNavigationMock from '../../../testing/createNavigationPropMock'
import render from '../../../testing/render'
import ArticleChoiceExerciseScreen from '../ArticleChoiceExerciseScreen'

jest.useFakeTimers()

jest.mock('../../../services/helpers', () => ({
...jest.requireActual('../../../services/helpers'),
shuffleArray: jest.fn(it => it)
}))

jest.mock('../../../services/AsyncStorage')
jest.mock('../../../services/AsyncStorage', () => ({
getExerciseProgress: jest.fn(() => Promise.resolve({})),
saveExerciseProgress: jest.fn()
}))

jest.mock('../../../components/AudioPlayer', () => {
const Text = require('react-native').Text
Expand Down
10 changes: 5 additions & 5 deletions src/routes/choice-exercises/components/SingleChoiceExercise.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { Answer, BUTTONS_THEME, numberOfMaxRetries, SIMPLE_RESULTS, SimpleResult
import { AlternativeWord, Document } from '../../../constants/endpoints'
import labels from '../../../constants/labels.json'
import { DocumentResult, RoutesParams } from '../../../navigation/NavigationTypes'
import { saveExerciseProgress } from '../../../services/AsyncStorage'
import { getExerciseProgress, saveExerciseProgress } from '../../../services/AsyncStorage'
import { moveToEnd, shuffleArray } from '../../../services/helpers'
import { SingleChoice } from './SingleChoice'

Expand Down Expand Up @@ -81,16 +81,16 @@ const ChoiceExerciseScreen = ({
}, [results, currentWord])

const onExerciseFinished = async (results: DocumentResult[]): Promise<void> => {
if (disciplineId) {
await saveExerciseProgress(disciplineId, exerciseKey, results)
}
const progress = await getExerciseProgress()
await saveExerciseProgress(disciplineId, exerciseKey, results)
navigation.navigate('ExerciseFinished', {
documents,
disciplineId,
disciplineTitle,
exercise: exerciseKey,
results,
closeExerciseAction: route.params.closeExerciseAction
closeExerciseAction: route.params.closeExerciseAction,
unlockedNextExercise: progress[disciplineId]?.[exerciseKey] === undefined
})
initializeExercise(true)
}
Expand Down
46 changes: 19 additions & 27 deletions src/routes/exercise-finished/ExerciseFinishedScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ const Root = styled.View`
height: 100%;
align-items: center;
`
const UpperSection = styled.View<{ exerciseUnlocked: boolean }>`
const UpperSection = styled.View<{ unlockedNextExercise: boolean }>`
width: 140%;
height: 45%;
background-color: ${prop => (prop.exerciseUnlocked ? prop.theme.colors.correct : prop.theme.colors.primary)};
background-color: ${prop => (prop.unlockedNextExercise ? prop.theme.colors.correct : prop.theme.colors.primary)};
border-bottom-left-radius: ${hp('60%')}px;
border-bottom-right-radius: ${hp('60%')}px;
margin-bottom: ${props => props.theme.spacings.lg};
Expand All @@ -38,8 +38,8 @@ const MessageContainer = styled.View`
width: 60%;
margin-top: ${props => props.theme.spacings.sm};
`
const Message = styled(HeadingBackground)<{ exerciseUnlocked: boolean }>`
color: ${prop => (prop.exerciseUnlocked ? prop.theme.colors.primary : prop.theme.colors.background)};
const Message = styled(HeadingBackground)<{ unlockedNextExercise: boolean }>`
color: ${prop => (prop.unlockedNextExercise ? prop.theme.colors.primary : prop.theme.colors.background)};
text-align: center;
`
const Icon = styled.TouchableOpacity`
Expand All @@ -54,16 +54,14 @@ interface Props {
}

const ExerciseFinishedScreen = ({ navigation, route }: Props): ReactElement => {
const { exercise, results, disciplineTitle, disciplineId, documents, closeExerciseAction } = route.params
const { exercise, results, disciplineTitle, disciplineId, documents, closeExerciseAction, unlockedNextExercise } =
route.params
const [message, setMessage] = React.useState<string>('')
// eslint-disable-next-line
const exerciseUnlocked = false // TODO: LUN-131 logic

React.useEffect(() => {
const correctResults = results.filter(doc => doc.result === 'correct')
const correct = correctResults.length / results.length
// eslint-disable-next-line
if (exerciseUnlocked) {
if (unlockedNextExercise) {
setMessage(labels.results.unlockedExercise)
} else if (correct > 2 / 3) {
setMessage(labels.results.feedbackGood)
Expand All @@ -89,27 +87,21 @@ const ExerciseFinishedScreen = ({ navigation, route }: Props): ReactElement => {

return (
<Root>
<UpperSection exerciseUnlocked={exerciseUnlocked}>
<UpperSection unlockedNextExercise={unlockedNextExercise}>
<Icon onPress={checkResults}>
{
// eslint-disable-next-line
exerciseUnlocked ? (
<CloseIcon width={wp('6%')} height={wp('6%')} />
) : (
<CloseIconWhite width={wp('6%')} height={wp('6%')} />
)
}
</Icon>
{
// eslint-disable-next-line
exerciseUnlocked ? (
<OpenLockIcon width={wp('8%')} height={wp('8%')} />
{unlockedNextExercise ? (
<CloseIcon width={wp('6%')} height={wp('6%')} />
) : (
<CheckCircleIconWhite width={wp('8%')} height={wp('8%')} />
)
}
<CloseIconWhite width={wp('6%')} height={wp('6%')} />
)}
</Icon>
{unlockedNextExercise ? (
<OpenLockIcon width={wp('8%')} height={wp('8%')} />
) : (
<CheckCircleIconWhite width={wp('8%')} height={wp('8%')} />
)}
<MessageContainer>
<Message exerciseUnlocked={exerciseUnlocked}>{message}</Message>
<Message unlockedNextExercise={unlockedNextExercise}>{message}</Message>
</MessageContainer>
</UpperSection>

Expand Down
37 changes: 20 additions & 17 deletions src/routes/exercises/ExercisesScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { CommonActions, RouteProp } from '@react-navigation/native'
import { CommonActions, RouteProp, useFocusEffect } from '@react-navigation/native'
import { StackNavigationProp } from '@react-navigation/stack'
import React, { useState } from 'react'
import React, { useCallback, useState } from 'react'
import { FlatList } from 'react-native'
import { widthPercentageToDP as wp } from 'react-native-responsive-screen'
import styled from 'styled-components/native'
Expand All @@ -15,7 +15,8 @@ import { Exercise, EXERCISES } from '../../constants/data'
import labels from '../../constants/labels.json'
import useLoadDocuments from '../../hooks/useLoadDocuments'
import { RoutesParams } from '../../navigation/NavigationTypes'
import { wordsDescription } from '../../services/helpers'
import { getDoneExercises, wordsDescription } from '../../services/helpers'
import { reportError } from '../../services/sentry'
import LockingLane from './components/LockingLane'

const Root = styled.View`
Expand Down Expand Up @@ -45,6 +46,7 @@ interface ExercisesScreenProps {
const ExercisesScreen = ({ route, navigation }: ExercisesScreenProps): JSX.Element => {
const { discipline, disciplineTitle, disciplineId } = route.params
const [isModalVisible, setIsModalVisible] = useState<boolean>(false)
const [nextExercise, setNextExercise] = useState<Exercise | null>(EXERCISES[0])

const {
data: documents,
Expand All @@ -56,10 +58,16 @@ const ExercisesScreen = ({ route, navigation }: ExercisesScreenProps): JSX.Eleme
apiKey: discipline.apiKey
})

const currentExercise: Exercise = EXERCISES[2] // TODO: LUN-131 logic , note: currentExercise is the last level that can be accessed.
useFocusEffect(
useCallback(() => {
getDoneExercises(disciplineId)
.then(value => setNextExercise(value < EXERCISES.length ? EXERCISES[value] : null))
.catch(reportError)
}, [disciplineId])
)

const handleNavigation = (item: Exercise): void => {
if (item.level > currentExercise.level) {
if (nextExercise && item.level > nextExercise.level) {
setIsModalVisible(true)
return
}
Expand All @@ -81,13 +89,13 @@ const ExercisesScreen = ({ route, navigation }: ExercisesScreenProps): JSX.Eleme

const Item = ({ item, index }: { item: Exercise; index: number }): JSX.Element | null => (
<Container>
<LockingLane current={currentExercise} index={index} />
<LockingLane nextExercise={nextExercise} index={index} />
<ListItemResizer>
<ListItem
title={item.title}
description={item.description}
onPress={() => handleNavigation(item)}
arrowDisabled={item.level > currentExercise.level}>
arrowDisabled={nextExercise === null || item.level > nextExercise.level}>
<Trophy level={item.level} />
</ListItem>
</ListItemResizer>
Expand All @@ -96,25 +104,20 @@ const ExercisesScreen = ({ route, navigation }: ExercisesScreenProps): JSX.Eleme

return (
<Root>
{documents && (
{documents && nextExercise && (
<CustomModal
onClose={() => setIsModalVisible(false)}
visible={isModalVisible}
text={labels.exercises.lockedExerciseModal.title}
confirmationButtonText={labels.exercises.lockedExerciseModal.confirmButtonLabel}
confirmationAction={() => {
handleNavigation(currentExercise)
handleNavigation(nextExercise)
setIsModalVisible(false)
}}>
}}
testID='locking-modal'>
<SmallMessage>
{labels.exercises.lockedExerciseModal.descriptionPart1}
<ContentTextBold>
{' '}
{
// eslint-disable-next-line
EXERCISES[currentExercise.level] !== undefined ? currentExercise.title : EXERCISES[0].title
}{' '}
</ContentTextBold>
<ContentTextBold>{` ${nextExercise.title} `}</ContentTextBold>
{labels.exercises.lockedExerciseModal.descriptionPart2}
</SmallMessage>
</CustomModal>
Expand Down
68 changes: 68 additions & 0 deletions src/routes/exercises/__tests__/ExercisesScreen.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import RNAsyncStorage from '@react-native-async-storage/async-storage'
import { RouteProp } from '@react-navigation/native'
import { fireEvent } from '@testing-library/react-native'
import { mocked } from 'jest-mock'
import React from 'react'

import { EXERCISES } 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 render from '../../../testing/render'
import ExercisesScreen from '../ExercisesScreen'

jest.mock('@react-navigation/native')
jest.mock('../../../hooks/useLoadDocuments')

describe('ExercisesScreen', () => {
const documents = new DocumentBuilder(1).build()
beforeEach(() => {
jest.clearAllMocks()
RNAsyncStorage.clear()
mocked(useLoadDocuments).mockReturnValue(getReturnOf(documents))
})

const navigation = createNavigationMock<'Exercises'>()
const route: RouteProp<RoutesParams, 'Exercises'> = {
key: 'key-0',
name: 'Exercises',
params: {
disciplineId: mockDisciplines()[0].id,
disciplineTitle: mockDisciplines()[0].title,
discipline: mockDisciplines()[0],
documents: null
}
}

it('should render correctly', () => {
const { getAllByText } = render(<ExercisesScreen route={route} navigation={navigation} />)
EXERCISES.forEach(exercise => {
expect(getAllByText(exercise.title)).toBeDefined()
expect(getAllByText(exercise.description)).toBeDefined()
})
})

it('should show modal on navigation if locked', async () => {
const { getByText, getByTestId } = render(<ExercisesScreen route={route} navigation={navigation} />)
expect(getByTestId('locking-modal')).toHaveProp('visible', false)
const lockedExercise = getByText(EXERCISES[1].title)
fireEvent.press(lockedExercise)
expect(getByTestId('locking-modal')).toHaveProp('visible', true)
expect(navigation.navigate).not.toHaveBeenCalled()
})

it('should trigger navigation if unlocked', () => {
const { getAllByText } = render(<ExercisesScreen route={route} navigation={navigation} />)
const nextExercise = getAllByText(EXERCISES[0].title)
fireEvent.press(nextExercise[1])
expect(navigation.navigate).toHaveBeenCalledWith(EXERCISES[0].screen, {
closeExerciseAction: undefined,
disciplineId: mockDisciplines()[0].id,
disciplineTitle: mockDisciplines()[0].title,
documents
})
})
})
12 changes: 6 additions & 6 deletions src/routes/exercises/components/LockingLane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,22 @@ const Line = styled.View<{ color: string }>`
`

interface PropsType {
current: Exercise
nextExercise: Exercise | null
index: number
}

const LockingLane = ({ current, index }: PropsType): ReactElement => {
const LockingLane = ({ nextExercise, index }: PropsType): ReactElement => {
let Icon
if (current.level < index) {
if (nextExercise && nextExercise.level < index) {
Icon = LockIcon
} else if (current.level === index) {
} else if (nextExercise && nextExercise.level === index) {
Icon = CircleIconBlue
} else {
Icon = CheckCircleIconBlue
}

const colorPre = current.level < index ? COLORS.black : COLORS.lockingLane
const colorPost = current.level <= index ? COLORS.black : COLORS.lockingLane
const colorPre = nextExercise && nextExercise.level < index ? COLORS.black : COLORS.lockingLane
const colorPost = nextExercise && nextExercise.level <= index ? COLORS.black : COLORS.lockingLane

return (
<Container>
Expand Down
7 changes: 5 additions & 2 deletions src/routes/home/components/ProfessionDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CommonActions, useFocusEffect, useNavigation } from '@react-navigation/native'
import { StackNavigationProp } from '@react-navigation/stack'
import React, { ReactElement, useEffect, useState } from 'react'
import { Pressable } from 'react-native'
import * as Progress from 'react-native-progress'
import styled from 'styled-components/native'

Expand Down Expand Up @@ -56,8 +57,10 @@ const ProfessionDetails = ({ discipline }: PropsType): ReactElement => {
}
}

const navigateToDisciplines = () => navigation.navigate('DisciplineSelection', { discipline }) // TODO remove in LUN-328

return (
<>
<Pressable onPress={navigateToDisciplines}>
<ProgressContainer>
<Progress.Circle
progress={progress ?? 0}
Expand Down Expand Up @@ -85,7 +88,7 @@ const ProfessionDetails = ({ discipline }: PropsType): ReactElement => {
disabled={documents === null || nextExercise === null}
/>
</ButtonContainer>
</>
</Pressable>
)
}

Expand Down
3 changes: 2 additions & 1 deletion src/routes/write-exercise/WriteExerciseScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ const WriteExerciseScreen = ({ route, navigation }: WriteExerciseScreenProps): R
disciplineId,
results: documentsWithResults,
exercise: ExerciseKeys.writeExercise,
closeExerciseAction
closeExerciseAction,
unlockedNextExercise: false
})
initializeExercise(true)
}
Expand Down
Loading

0 comments on commit 58724c8

Please sign in to comment.