diff --git a/assets/images/index.ts b/assets/images/index.ts
index 7aae81044..e52dc216d 100644
--- a/assets/images/index.ts
+++ b/assets/images/index.ts
@@ -45,8 +45,11 @@ import OpenLockIcon from './open-lock-icon.svg'
import QRCodeIcon from './qr-code-icon.svg'
import RepeatIcon from './repeat-icon.svg'
import ShareIcon from './share-icon.svg'
-import StartIconGrey from './star-icon-grey.svg'
-import StartIconWhite from './star-icon-white.svg'
+import StarCircleIconGreyFilled from './star-circle-icon-grey-filled.svg'
+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 TrashIcon from './trash-bin-icon.svg'
import TrophyIcon from './trophy-icon.svg'
import VolumeUpCircleIcon from './volume-up-circle-icon.svg'
@@ -99,8 +102,11 @@ export {
QRCodeIcon,
RepeatIcon,
ShareIcon,
- StartIconGrey,
- StartIconWhite,
+ StarCircleIconGrey,
+ StarCircleIconGreyFilled,
+ StarIconGrey,
+ StarIconGreyFilled,
+ StarIconWhite,
TrashIcon,
TrophyIcon,
VolumeUpCircleIcon
diff --git a/assets/images/star-circle-icon-grey-filled.svg b/assets/images/star-circle-icon-grey-filled.svg
new file mode 100644
index 000000000..814d1651d
--- /dev/null
+++ b/assets/images/star-circle-icon-grey-filled.svg
@@ -0,0 +1,6 @@
+
diff --git a/assets/images/star-circle-icon-grey.svg b/assets/images/star-circle-icon-grey.svg
new file mode 100644
index 000000000..ed2857cae
--- /dev/null
+++ b/assets/images/star-circle-icon-grey.svg
@@ -0,0 +1,6 @@
+
diff --git a/assets/images/star-icon-grey-filled.svg b/assets/images/star-icon-grey-filled.svg
new file mode 100644
index 000000000..ca8713c5f
--- /dev/null
+++ b/assets/images/star-icon-grey-filled.svg
@@ -0,0 +1,3 @@
+
diff --git a/release-notes/unreleased/LUN-132-favorites.yml b/release-notes/unreleased/LUN-132-favorites.yml
new file mode 100644
index 000000000..5f8ff2a60
--- /dev/null
+++ b/release-notes/unreleased/LUN-132-favorites.yml
@@ -0,0 +1,6 @@
+issue_key: LUN-132
+show_in_stores: true
+platforms:
+ - android
+ - ios
+de: Favoriten können nun gespeichert werden.
diff --git a/src/components/AudioPlayer.tsx b/src/components/AudioPlayer.tsx
index 0af8e23c3..95af7cb65 100644
--- a/src/components/AudioPlayer.tsx
+++ b/src/components/AudioPlayer.tsx
@@ -15,14 +15,7 @@ export interface AudioPlayerProps {
submittedAlternative?: string | null
}
-const StyledView = styled.View`
- align-items: center;
- margin-bottom: ${props => props.theme.spacings.sm};
-`
-
const VolumeIcon = styled.TouchableOpacity<{ disabled: boolean; isActive: boolean }>`
- position: absolute;
- top: ${wp('-4.5%')}px;
width: ${wp('9%')}px;
height: ${wp('9%')}px;
border-radius: 50px;
@@ -107,15 +100,13 @@ const AudioPlayer = ({ document, disabled, submittedAlternative }: AudioPlayerPr
}
return (
-
-
-
-
-
+
+
+
)
}
diff --git a/src/components/DocumentImageSection.tsx b/src/components/DocumentImageSection.tsx
new file mode 100644
index 000000000..21d0766b7
--- /dev/null
+++ b/src/components/DocumentImageSection.tsx
@@ -0,0 +1,49 @@
+import React, { ReactElement } from 'react'
+import { widthPercentageToDP as wp } from 'react-native-responsive-screen'
+import styled from 'styled-components/native'
+
+import { Document } from '../constants/endpoints'
+import AudioPlayer from './AudioPlayer'
+import FavoriteButton from './FavoriteButton'
+import ImageCarousel from './ImageCarousel'
+
+const AudioContainer = styled.View`
+ position: absolute;
+ bottom: ${wp('-4.5%')}px;
+ align-self: center;
+`
+const FavoriteContainer = styled.View`
+ position: absolute;
+ top: 0;
+ right: ${props => props.theme.spacings.md};
+`
+
+const Container = styled.View`
+ margin-bottom: ${props => props.theme.spacings.md};
+`
+
+interface Props {
+ document: Document
+ audioDisabled?: boolean
+ minimized?: boolean
+ submittedAlternative?: string | null
+}
+
+const DocumentImageSection = ({
+ document,
+ audioDisabled = false,
+ minimized = false,
+ submittedAlternative
+}: Props): ReactElement => (
+
+
+
+
+
+
+
+
+
+)
+
+export default DocumentImageSection
diff --git a/src/components/FavoriteButton.tsx b/src/components/FavoriteButton.tsx
new file mode 100644
index 000000000..e6e5779dd
--- /dev/null
+++ b/src/components/FavoriteButton.tsx
@@ -0,0 +1,62 @@
+import { useFocusEffect } from '@react-navigation/native'
+import React, { ReactElement } from 'react'
+import { widthPercentageToDP as wp } from 'react-native-responsive-screen'
+import styled from 'styled-components/native'
+
+import { StarCircleIconGrey, StarCircleIconGreyFilled } from '../../assets/images'
+import { Document } from '../constants/endpoints'
+import useLoadAsync from '../hooks/useLoadAsync'
+import AsyncStorage from '../services/AsyncStorage'
+import { reportError } from '../services/sentry'
+
+const Icon = styled(StarCircleIconGreyFilled)`
+ min-width: ${wp('9%')}px;
+ min-height: ${wp('9%')}px;
+`
+const IconOutline = styled(StarCircleIconGrey)`
+ min-width: ${wp('9%')}px;
+ min-height: ${wp('9%')}px;
+`
+const Button = styled.TouchableOpacity`
+ justify-content: center;
+ align-items: center;
+ shadow-color: ${props => props.theme.colors.shadow};
+ shadow-radius: 5px;
+ shadow-offset: 1px 1px;
+ shadow-opacity: 0.5;
+`
+
+interface Props {
+ document: Document
+ onFavoritesChanged?: () => void
+}
+
+const FavoriteButton = ({ document, onFavoritesChanged }: Props): ReactElement | null => {
+ const { data: isFavorite, refresh } = useLoadAsync(AsyncStorage.isFavorite, document.id)
+
+ useFocusEffect(refresh)
+
+ const onPress = async () => {
+ if (isFavorite) {
+ await AsyncStorage.removeFavorite(document.id).catch(reportError)
+ } else {
+ await AsyncStorage.addFavorite(document.id).catch(reportError)
+ }
+ refresh()
+ if (onFavoritesChanged) {
+ onFavoritesChanged()
+ }
+ }
+
+ if (isFavorite === null) {
+ return null
+ }
+
+ return (
+
+ )
+}
+
+export default FavoriteButton
diff --git a/src/components/VocabularyList.tsx b/src/components/VocabularyList.tsx
index 63b7626ca..68d53fb61 100644
--- a/src/components/VocabularyList.tsx
+++ b/src/components/VocabularyList.tsx
@@ -17,11 +17,18 @@ const Root = styled.View`
interface VocabularyListScreenProps {
documents: Document[]
onItemPress: (index: number) => void
+ onFavoritesChanged?: () => void
+ title: string
}
-const VocabularyList = ({ documents, onItemPress }: VocabularyListScreenProps): JSX.Element => {
+const VocabularyList = ({
+ documents,
+ onItemPress,
+ onFavoritesChanged,
+ title
+}: VocabularyListScreenProps): JSX.Element => {
const renderItem = ({ item, index }: { item: Document; index: number }): JSX.Element => (
- onItemPress(index)} />
+ onItemPress(index)} onFavoritesChanged={onFavoritesChanged} />
)
return (
@@ -29,7 +36,7 @@ const VocabularyList = ({ documents, onItemPress }: VocabularyListScreenProps):
}
diff --git a/src/components/VocabularyListItem.tsx b/src/components/VocabularyListItem.tsx
index d3caf3a46..b2baf8aff 100644
--- a/src/components/VocabularyListItem.tsx
+++ b/src/components/VocabularyListItem.tsx
@@ -5,6 +5,7 @@ import styled from 'styled-components/native'
import { Document } from '../constants/endpoints'
import { getArticleColor } from '../services/helpers'
import AudioPlayer from './AudioPlayer'
+import FavoriteButton from './FavoriteButton'
import ListItem from './ListItem'
import { ContentTextLight } from './text/Content'
@@ -24,17 +25,23 @@ const StyledTitle = styled(ContentTextLight)<{ articleColor: string }>`
height: ${wp('5%')}px;
text-align: center;
`
-const Speaker = styled.View`
- padding-right: ${props => props.theme.spacings.xl};
- padding-top: ${props => props.theme.spacings.sm};
+const RightChildrenContainer = styled.View`
+ flex-direction: row;
+ justify-content: space-between;
+`
+
+const FavButtonContainer = styled.View`
+ padding: ${props => `0 ${props.theme.spacings.xs} 0 ${props.theme.spacings.sm}`};
+ align-self: center;
`
interface VocabularyListItemProps {
document: Document
onPress: () => void
+ onFavoritesChanged?: () => void
}
-const VocabularyListItem = ({ document, onPress }: VocabularyListItemProps): ReactElement => {
+const VocabularyListItem = ({ document, onPress, onFavoritesChanged }: VocabularyListItemProps): ReactElement => {
const { article, word, document_image: documentImage } = document
const title = {article.value}
@@ -50,9 +57,12 @@ const VocabularyListItem = ({ document, onPress }: VocabularyListItemProps): Rea
onPress={onPress}
icon={icon}
rightChildren={
-
+
-
+
+
+
+
}
/>
)
diff --git a/src/components/__tests__/FavoriteButton.spec.tsx b/src/components/__tests__/FavoriteButton.spec.tsx
new file mode 100644
index 000000000..7bf6dbc6d
--- /dev/null
+++ b/src/components/__tests__/FavoriteButton.spec.tsx
@@ -0,0 +1,45 @@
+import { NavigationContainer } from '@react-navigation/native'
+import { fireEvent, waitFor } from '@testing-library/react-native'
+import React from 'react'
+
+import AsyncStorage from '../../services/AsyncStorage'
+import DocumentBuilder from '../../testing/DocumentBuilder'
+import render from '../../testing/render'
+import FavoriteButton from '../FavoriteButton'
+
+describe('FavoriteButton', () => {
+ const document = new DocumentBuilder(1).build()[0]
+
+ const renderFavoriteButton = () =>
+ render(
+
+
+
+ )
+
+ it('should add favorite on click', async () => {
+ await AsyncStorage.setFavorites([])
+ await expect(AsyncStorage.isFavorite(document.id)).resolves.toBe(false)
+
+ const { getByTestId } = renderFavoriteButton()
+
+ await waitFor(() => expect(getByTestId('add')).toBeTruthy())
+ fireEvent.press(getByTestId('add'))
+
+ await waitFor(() => expect(getByTestId('remove')).toBeTruthy())
+ await expect(AsyncStorage.isFavorite(document.id)).resolves.toBe(true)
+ })
+
+ it('should remove favorite on click', async () => {
+ await AsyncStorage.setFavorites([document.id])
+ await expect(AsyncStorage.isFavorite(document.id)).resolves.toBe(true)
+
+ const { getByTestId } = renderFavoriteButton()
+
+ await waitFor(() => expect(getByTestId('remove')).toBeTruthy())
+ fireEvent.press(getByTestId('remove'))
+
+ await waitFor(() => expect(getByTestId('add')).toBeTruthy())
+ await expect(AsyncStorage.isFavorite(document.id)).resolves.toBe(false)
+ })
+})
diff --git a/src/components/__tests__/VocabularyList.spec.tsx b/src/components/__tests__/VocabularyList.spec.tsx
index 04affd344..54f53a576 100644
--- a/src/components/__tests__/VocabularyList.spec.tsx
+++ b/src/components/__tests__/VocabularyList.spec.tsx
@@ -7,6 +7,10 @@ import { mockUseLoadAsyncWithData } from '../../testing/mockUseLoadFromEndpoint'
import render from '../../testing/render'
import VocabularyList from '../VocabularyList'
+jest.mock('../FavoriteButton', () => () => {
+ const { Text } = require('react-native')
+ return FavoriteButton
+})
jest.mock('../AudioPlayer', () => {
const Text = require('react-native').Text
return () => AudioPlayer
@@ -24,10 +28,10 @@ describe('VocabularyList', () => {
mockUseLoadAsyncWithData(documents)
const { getByText, getAllByText, getAllByTestId } = render(
-
+
)
- expect(getByText(labels.exercises.vocabularyList.title)).toBeTruthy()
+ expect(getByText('Title')).toBeTruthy()
expect(getByText(`2 ${labels.general.words}`)).toBeTruthy()
expect(getByText('Der')).toBeTruthy()
expect(getByText('Spachtel')).toBeTruthy()
@@ -38,7 +42,7 @@ describe('VocabularyList', () => {
})
it('should call onItemPress with correct index', () => {
- const { getByText } = render()
+ const { getByText } = render()
expect(onItemPress).toHaveBeenCalledTimes(0)
diff --git a/src/components/__tests__/VocabularyListItem.spec.tsx b/src/components/__tests__/VocabularyListItem.spec.tsx
index ed668dc66..18a833668 100644
--- a/src/components/__tests__/VocabularyListItem.spec.tsx
+++ b/src/components/__tests__/VocabularyListItem.spec.tsx
@@ -7,6 +7,10 @@ import { Document } from '../../constants/endpoints'
import render from '../../testing/render'
import VocabularyListItem from '../VocabularyListItem'
+jest.mock('../FavoriteButton', () => () => {
+ const { Text } = require('react-native')
+ return FavoriteButton
+})
jest.mock('../AudioPlayer', () => () => null)
describe('VocabularyListItem', () => {
diff --git a/src/constants/endpoints.ts b/src/constants/endpoints.ts
index 3c7a3b92d..494f251eb 100644
--- a/src/constants/endpoints.ts
+++ b/src/constants/endpoints.ts
@@ -41,7 +41,8 @@ export const ENDPOINTS = {
groupInfo: 'group_info',
trainingSet: 'training_set',
trainingSets: 'training_sets',
- documents: 'documents/:id'
+ documents: 'documents/:id',
+ document: 'words'
}
export const ForbiddenError = 'Request failed with status code 403'
diff --git a/src/constants/labels.json b/src/constants/labels.json
index 36c8d93dd..ea2fcf444 100644
--- a/src/constants/labels.json
+++ b/src/constants/labels.json
@@ -147,5 +147,6 @@
"word": "Wort",
"words": "Wörter",
"customModalCancel": "Zurück"
- }
+ },
+ "favorites": "Favoriten"
}
diff --git a/src/hooks/useLoadDocuments.ts b/src/hooks/useLoadDocuments.ts
index ee2c635e8..70b631a69 100644
--- a/src/hooks/useLoadDocuments.ts
+++ b/src/hooks/useLoadDocuments.ts
@@ -17,7 +17,7 @@ export interface DocumentFromServer {
alternatives: AlternativeWordFromServer[]
}
-const formatServerResponse = (documents: DocumentFromServer[]): Document[] =>
+export const formatServerResponse = (documents: DocumentFromServer[]): Document[] =>
documents.map(document => ({
...document,
article: ARTICLES[document.article],
diff --git a/src/hooks/useLoadFavorites.ts b/src/hooks/useLoadFavorites.ts
new file mode 100644
index 000000000..58dce9dbe
--- /dev/null
+++ b/src/hooks/useLoadFavorites.ts
@@ -0,0 +1,21 @@
+import { Document, ENDPOINTS } from '../constants/endpoints'
+import AsyncStorage from '../services/AsyncStorage'
+import { getFromEndpoint } from '../services/axios'
+import useLoadAsync, { Return } from './useLoadAsync'
+import { DocumentFromServer, formatServerResponse } from './useLoadDocuments'
+
+export const loadFavorites = async (): Promise => {
+ const favoriteIds = await AsyncStorage.getFavorites()
+ const documents = await Promise.all(
+ favoriteIds.map(id => {
+ const url = `${ENDPOINTS.document}/${id}`
+ return getFromEndpoint(url)
+ })
+ )
+
+ return formatServerResponse(documents)
+}
+
+const useLoadFavorites = (): Return => useLoadAsync(loadFavorites, {})
+
+export default useLoadFavorites
diff --git a/src/navigation/BottomTabNavigator.tsx b/src/navigation/BottomTabNavigator.tsx
index 8311dc2ab..ee1eb50c9 100644
--- a/src/navigation/BottomTabNavigator.tsx
+++ b/src/navigation/BottomTabNavigator.tsx
@@ -4,8 +4,9 @@ import { SafeAreaView } from 'react-native'
import { widthPercentageToDP as wp } from 'react-native-responsive-screen'
import { useTheme } from 'styled-components/native'
-import { HomeIconGrey, HomeIconWhite } from '../../assets/images'
+import { HomeIconGrey, HomeIconWhite, StarIconGrey, StarIconWhite } from '../../assets/images'
import labels from '../constants/labels.json'
+import FavoritesScreen from '../routes/FavoritesScreen'
import HomeStackNavigator from './HomeStackNavigator'
import { RoutesParams } from './NavigationTypes'
@@ -22,9 +23,7 @@ const BottomTabNavigator = (): JSX.Element | null => {
tabBarActiveTintColor: theme.colors.background,
tabBarStyle: {
backgroundColor: theme.colors.primary,
- height: wp('14%'),
- // TODO LUN-132: Delete
- display: 'none'
+ height: wp('14%')
},
tabBarItemStyle: { height: wp('14%'), padding: wp('2%') },
tabBarLabelStyle: { fontSize: wp('3%') }
@@ -42,20 +41,19 @@ const BottomTabNavigator = (): JSX.Element | null => {
title: labels.general.home
}}
/>
- {/* TODO LUN-132: Uncomment */}
- {/* */}
- {/* focused ? ( */}
- {/* */}
- {/* ) : ( */}
- {/* */}
- {/* ), */}
- {/* title: labels.general.favorites */}
- {/* }} */}
- {/* /> */}
+
+ focused ? (
+
+ ) : (
+
+ ),
+ title: labels.general.favorites
+ }}
+ />
{/* {
documentIndex: number
+ disciplineId: number | null
}
export interface ExercisesParams extends Omit {
@@ -51,7 +52,7 @@ export type RoutesParams = {
discipline: Discipline
initialSelection: boolean
}
- VocabularyDetail: DetailExerciseParams
+ VocabularyDetail: VocabularyDetailExerciseParams
Exercises: ExercisesParams
VocabularyList: ExerciseParams
WordChoiceExercise: ExerciseParams
@@ -66,6 +67,7 @@ export type RoutesParams = {
}
Imprint: undefined
ManageDisciplines: undefined
+ Favorites: undefined
}
export type Route = keyof RoutesParams
diff --git a/src/routes/FavoritesScreen.tsx b/src/routes/FavoritesScreen.tsx
new file mode 100644
index 000000000..7af95de6e
--- /dev/null
+++ b/src/routes/FavoritesScreen.tsx
@@ -0,0 +1,48 @@
+import { CommonActions, RouteProp, useFocusEffect } from '@react-navigation/native'
+import { StackNavigationProp } from '@react-navigation/stack'
+import React, { ReactElement } from 'react'
+
+import ServerResponseHandler from '../components/ServerResponseHandler'
+import VocabularyList from '../components/VocabularyList'
+import labels from '../constants/labels.json'
+import useLoadFavorites from '../hooks/useLoadFavorites'
+import { RoutesParams } from '../navigation/NavigationTypes'
+
+interface FavoritesScreenProps {
+ route: RouteProp
+ navigation: StackNavigationProp
+}
+
+const FavoritesScreen = ({ navigation }: FavoritesScreenProps): ReactElement => {
+ const { data, error, refresh } = useLoadFavorites()
+
+ useFocusEffect(refresh)
+
+ const onItemPress = (index: number) => {
+ if (!data) {
+ return
+ }
+ navigation.navigate('VocabularyDetail', {
+ disciplineId: null,
+ disciplineTitle: labels.general.favorites,
+ documents: data,
+ documentIndex: index,
+ closeExerciseAction: CommonActions.goBack()
+ })
+ }
+
+ return (
+
+ {data && (
+
+ )}
+
+ )
+}
+
+export default FavoritesScreen
diff --git a/src/routes/VocabularyDetailScreen.tsx b/src/routes/VocabularyDetailScreen.tsx
index e3d873359..cdb583b15 100644
--- a/src/routes/VocabularyDetailScreen.tsx
+++ b/src/routes/VocabularyDetailScreen.tsx
@@ -4,15 +4,18 @@ import React, { ReactElement } from 'react'
import styled from 'styled-components/native'
import { ArrowRightIcon } from '../../assets/images'
-import AudioPlayer from '../components/AudioPlayer'
import Button from '../components/Button'
+import DocumentImageSection from '../components/DocumentImageSection'
import ExerciseHeader from '../components/ExerciseHeader'
-import ImageCarousel from '../components/ImageCarousel'
import WordItem from '../components/WordItem'
import { BUTTONS_THEME } from '../constants/data'
import labels from '../constants/labels.json'
import { RoutesParams } from '../navigation/NavigationTypes'
+const Container = styled.View`
+ flex: 1;
+`
+
const ItemContainer = styled.View`
margin: ${props => props.theme.spacings.xl} 0;
height: 10%;
@@ -33,22 +36,21 @@ interface VocabularyDetailScreenProps {
const VocabularyDetailScreen = ({ route, navigation }: VocabularyDetailScreenProps): ReactElement => {
const { documents, documentIndex } = route.params
const document = documents[documentIndex]
- const { word, article, document_image: image } = document
+ const { word, article } = document
const hasNextDocument = documentIndex + 1 < documents.length
const goToNextWord = () =>
navigation.navigate('VocabularyDetail', { ...route.params, documentIndex: documentIndex + 1 })
return (
- <>
+
-
-
+
@@ -68,7 +70,7 @@ const VocabularyDetailScreen = ({ route, navigation }: VocabularyDetailScreenPro
/>
)}
- >
+
)
}
diff --git a/src/routes/VocabularyListScreen.tsx b/src/routes/VocabularyListScreen.tsx
index 9da9bdc60..39dc8dacf 100644
--- a/src/routes/VocabularyListScreen.tsx
+++ b/src/routes/VocabularyListScreen.tsx
@@ -5,6 +5,7 @@ import React, { useEffect } from 'react'
import ExerciseHeader from '../components/ExerciseHeader'
import VocabularyList from '../components/VocabularyList'
import { ExerciseKeys } from '../constants/data'
+import labels from '../constants/labels.json'
import { RoutesParams } from '../navigation/NavigationTypes'
import AsyncStorage from '../services/AsyncStorage'
import { reportError } from '../services/sentry'
@@ -27,7 +28,11 @@ const VocabularyListScreen = ({ route, navigation }: VocabularyListScreenProps):
return (
<>
-
+
>
)
}
diff --git a/src/routes/__tests__/VocabularyListScreen.spec.tsx b/src/routes/__tests__/VocabularyListScreen.spec.tsx
index 56cf03483..e065c9114 100644
--- a/src/routes/__tests__/VocabularyListScreen.spec.tsx
+++ b/src/routes/__tests__/VocabularyListScreen.spec.tsx
@@ -10,6 +10,11 @@ import { mockUseLoadAsyncWithData } from '../../testing/mockUseLoadFromEndpoint'
import render from '../../testing/render'
import VocabularyListScreen from '../VocabularyListScreen'
+jest.mock('../../components/FavoriteButton', () => {
+ const Text = require('react-native').Text
+ return () => FavoriteButton
+})
+
jest.mock('../../services/AsyncStorage', () => ({
setExerciseProgress: jest.fn(() => Promise.resolve())
}))
diff --git a/src/routes/choice-exercises/__tests__/ArticleChoiceExerciseScreen.spec.tsx b/src/routes/choice-exercises/__tests__/ArticleChoiceExerciseScreen.spec.tsx
index 9cd1859b0..c8942da73 100644
--- a/src/routes/choice-exercises/__tests__/ArticleChoiceExerciseScreen.spec.tsx
+++ b/src/routes/choice-exercises/__tests__/ArticleChoiceExerciseScreen.spec.tsx
@@ -11,6 +11,11 @@ import createNavigationMock from '../../../testing/createNavigationPropMock'
import render from '../../../testing/render'
import ArticleChoiceExerciseScreen from '../ArticleChoiceExerciseScreen'
+jest.mock('../../../components/FavoriteButton', () => () => {
+ const { Text } = require('react-native')
+ return FavoriteButton
+})
+
jest.mock('../../../services/helpers', () => ({
...jest.requireActual('../../../services/helpers'),
shuffleArray: jest.fn(it => it)
diff --git a/src/routes/choice-exercises/__tests__/WordChoiceExerciseScreen.spec.tsx b/src/routes/choice-exercises/__tests__/WordChoiceExerciseScreen.spec.tsx
index 6ed91626c..27c2453d0 100644
--- a/src/routes/choice-exercises/__tests__/WordChoiceExerciseScreen.spec.tsx
+++ b/src/routes/choice-exercises/__tests__/WordChoiceExerciseScreen.spec.tsx
@@ -11,6 +11,10 @@ import WordChoiceExerciseScreen from '../WordChoiceExerciseScreen'
jest.useFakeTimers()
+jest.mock('../../../components/FavoriteButton', () => {
+ const Text = require('react-native').Text
+ return () => FavoriteButton
+})
jest.mock('../../../services/helpers', () => ({
...jest.requireActual('../../../services/helpers'),
shuffleArray: jest.fn(it => it)
diff --git a/src/routes/choice-exercises/components/SingleChoiceExercise.tsx b/src/routes/choice-exercises/components/SingleChoiceExercise.tsx
index e5ab8ed30..d02b8121e 100644
--- a/src/routes/choice-exercises/components/SingleChoiceExercise.tsx
+++ b/src/routes/choice-exercises/components/SingleChoiceExercise.tsx
@@ -4,10 +4,9 @@ import React, { ReactElement, useEffect, useState, useCallback } from 'react'
import styled from 'styled-components/native'
import { ArrowRightIcon } from '../../../../assets/images'
-import AudioPlayer from '../../../components/AudioPlayer'
import Button from '../../../components/Button'
+import DocumentImageSection from '../../../components/DocumentImageSection'
import ExerciseHeader from '../../../components/ExerciseHeader'
-import ImageCarousel from '../../../components/ImageCarousel'
import { Answer, BUTTONS_THEME, numberOfMaxRetries, SIMPLE_RESULTS, SimpleResult } from '../../../constants/data'
import { AlternativeWord, Document } from '../../../constants/endpoints'
import labels from '../../../constants/labels.json'
@@ -138,8 +137,7 @@ const ChoiceExerciseScreen = ({
<>
- {document.document_image.length > 0 && }
-
+
{
const professionItems = selectedProfessions?.map(id => {
const unselectProfessionAndRefresh = () => {
- removeSelectedProfession(id).then(refreshSelectedProfessions).catch(reportError)
+ AsyncStorage.removeSelectedProfession(id).then(refreshSelectedProfessions).catch(reportError)
}
return
})
const customDisciplineItems = customDisciplines?.map(apiKey => {
const deleteCustomDisciplineAndRefresh = () => {
- removeCustomDiscipline(apiKey).then(refreshCustomDisciplines).catch(reportError)
+ AsyncStorage.removeCustomDiscipline(apiKey).then(refreshCustomDisciplines).catch(reportError)
}
return
})
diff --git a/src/routes/write-exercise/WriteExerciseScreen.tsx b/src/routes/write-exercise/WriteExerciseScreen.tsx
index 72f314394..63b4c3a08 100644
--- a/src/routes/write-exercise/WriteExerciseScreen.tsx
+++ b/src/routes/write-exercise/WriteExerciseScreen.tsx
@@ -8,7 +8,6 @@ import styled from 'styled-components/native'
import { ArrowRightIcon } from '../../../assets/images'
import Button from '../../components/Button'
import ExerciseHeader from '../../components/ExerciseHeader'
-import ImageCarousel from '../../components/ImageCarousel'
import { BUTTONS_THEME, ExerciseKeys, numberOfMaxRetries, SIMPLE_RESULTS } from '../../constants/data'
import labels from '../../constants/labels.json'
import { useIsKeyboardVisible } from '../../hooks/useIsKeyboardVisible'
@@ -17,13 +16,8 @@ import { saveExerciseProgress } from '../../services/AsyncStorage'
import { moveToEnd, shuffleArray } from '../../services/helpers'
import InteractionSection from './components/InteractionSection'
-const StyledContainer = styled.View`
- padding-top: ${props => props.theme.spacings.md};
- padding-bottom: ${props => props.theme.spacings.lg};
+const ButtonContainer = styled.View`
align-items: center;
- position: relative;
- width: 100%;
- height: 85%;
`
export interface WriteExerciseScreenProps {
@@ -121,15 +115,12 @@ const WriteExerciseScreen = ({ route, navigation }: WriteExerciseScreenProps): R
-
-
-
-
-
+
+
{isAnswerSubmitted && current.result !== 'similar' ? (
)}
-
+
)
}
diff --git a/src/routes/write-exercise/__tests__/WriteExerciseScreen.spec.tsx b/src/routes/write-exercise/__tests__/WriteExerciseScreen.spec.tsx
index 66ebb1f46..165a0d4d7 100644
--- a/src/routes/write-exercise/__tests__/WriteExerciseScreen.spec.tsx
+++ b/src/routes/write-exercise/__tests__/WriteExerciseScreen.spec.tsx
@@ -12,6 +12,11 @@ import createNavigationMock from '../../../testing/createNavigationPropMock'
import render from '../../../testing/render'
import WriteExerciseScreen from '../WriteExerciseScreen'
+jest.mock('../../../components/FavoriteButton', () => () => {
+ const { Text } = require('react-native')
+ return FavoriteButton
+})
+
jest.mock('../../../services/helpers', () => ({
...jest.requireActual('../../../services/helpers'),
shuffleArray: jest.fn(it => it)
diff --git a/src/routes/write-exercise/components/InteractionSection.tsx b/src/routes/write-exercise/components/InteractionSection.tsx
index bd335766a..9840f5fe5 100644
--- a/src/routes/write-exercise/components/InteractionSection.tsx
+++ b/src/routes/write-exercise/components/InteractionSection.tsx
@@ -1,14 +1,14 @@
import React, { ReactElement, useEffect, useRef, useState } from 'react'
import { Keyboard, Pressable, View } from 'react-native'
-import { widthPercentageToDP as wp } from 'react-native-responsive-screen'
import stringSimilarity from 'string-similarity'
import styled, { useTheme } from 'styled-components/native'
-import AudioPlayer from '../../../components/AudioPlayer'
import Button from '../../../components/Button'
import CustomTextInput from '../../../components/CustomTextInput'
+import DocumentImageSection from '../../../components/DocumentImageSection'
import { BUTTONS_THEME, numberOfMaxRetries, SIMPLE_RESULTS, SimpleResult } from '../../../constants/data'
import labels from '../../../constants/labels.json'
+import { useIsKeyboardVisible } from '../../../hooks/useIsKeyboardVisible'
import { DocumentResult } from '../../../navigation/NavigationTypes'
import { stringifyDocument } from '../../../services/helpers'
import Feedback from './Feedback'
@@ -19,8 +19,9 @@ const TextInputContainer = styled.View<{ styledBorderColor: string }>`
margin-bottom: ${props => props.theme.spacings.md};
`
-const Speaker = styled.View`
- top: ${wp('-6%')}px;
+const InputContainer = styled.View`
+ align-items: center;
+ margin-top: ${props => props.theme.spacings.md};
`
interface InteractionSectionProps {
@@ -41,6 +42,7 @@ const InteractionSection = (props: InteractionSectionProps): ReactElement => {
const [submittedInput, setSubmittedInput] = useState(null)
const theme = useTheme()
+ const isKeyboardShown = useIsKeyboardVisible()
const retryAllowed = !isAnswerSubmitted || documentWithResult.result === 'similar'
const isCorrect = documentWithResult.result === 'correct'
const needsToBeRepeated = documentWithResult.numberOfTries < numberOfMaxRetries && !isCorrect
@@ -115,46 +117,50 @@ const InteractionSection = (props: InteractionSectionProps): ReactElement => {
return (
<>
-
-
-
-
-
-
- {/* @ts-expect-error ref typing is off here */}
-
-
+
-
- {isAnswerSubmitted && (
-
- )}
- {retryAllowed && (
-
-
- )}
+ )}
+ {retryAllowed && (
+
+
+
+ )}
+
>
)
}
diff --git a/src/routes/write-exercise/components/__tests__/InteractionSection.spec.tsx b/src/routes/write-exercise/components/__tests__/InteractionSection.spec.tsx
index 6002b18b4..0b747c543 100644
--- a/src/routes/write-exercise/components/__tests__/InteractionSection.spec.tsx
+++ b/src/routes/write-exercise/components/__tests__/InteractionSection.spec.tsx
@@ -7,6 +7,11 @@ import { DocumentResult } from '../../../../navigation/NavigationTypes'
import render from '../../../../testing/render'
import InteractionSection from '../InteractionSection'
+jest.mock('../../../../components/FavoriteButton', () => () => {
+ const { Text } = require('react-native')
+ return FavoriteButton
+})
+
jest.mock('react-native-tts', () => ({
getInitStatus: jest.fn(async () => 'success'),
addListener: jest.fn(async () => ({ remove: jest.fn() }))
diff --git a/src/services/AsyncStorage.ts b/src/services/AsyncStorage.ts
index b57d325a4..3a472abe3 100644
--- a/src/services/AsyncStorage.ts
+++ b/src/services/AsyncStorage.ts
@@ -3,19 +3,22 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
import { ExerciseKey, Progress } from '../constants/data'
import { DocumentResult } from '../navigation/NavigationTypes'
-const progressKey = 'progress'
+const SELECTED_PROFESSIONS_KEY = 'selectedProfessions'
+const CUSTOM_DISCIPLINES_KEY = 'customDisciplines'
+const FAVORITES_KEY = 'favorites'
+const PROGRESS_KEY = 'progress'
// return value of null means the selected profession was never set before, therefore the intro screen must be shown
-export const getSelectedProfessions = async (): Promise => {
- const professions = await AsyncStorage.getItem('selectedProfessions')
+const getSelectedProfessions = async (): Promise => {
+ const professions = await AsyncStorage.getItem(SELECTED_PROFESSIONS_KEY)
return professions ? JSON.parse(professions) : null
}
-export const setSelectedProfessions = async (selectedProfessions: number[]): Promise => {
- await AsyncStorage.setItem('selectedProfessions', JSON.stringify(selectedProfessions))
+const setSelectedProfessions = async (selectedProfessions: number[]): Promise => {
+ await AsyncStorage.setItem(SELECTED_PROFESSIONS_KEY, JSON.stringify(selectedProfessions))
}
-export const pushSelectedProfession = async (professionId: number): Promise => {
+const pushSelectedProfession = async (professionId: number): Promise => {
let professions = await getSelectedProfessions()
if (professions === null) {
professions = [professionId]
@@ -26,7 +29,7 @@ export const pushSelectedProfession = async (professionId: number): Promise => {
+const removeSelectedProfession = async (professionId: number): Promise => {
const professions = await getSelectedProfessions()
if (professions === null) {
throw new Error('professions not set')
@@ -36,16 +39,16 @@ export const removeSelectedProfession = async (professionId: number): Promise => {
- const disciplines = await AsyncStorage.getItem('customDisciplines')
+const getCustomDisciplines = async (): Promise => {
+ const disciplines = await AsyncStorage.getItem(CUSTOM_DISCIPLINES_KEY)
return disciplines ? JSON.parse(disciplines) : []
}
-export const setCustomDisciplines = async (customDisciplines: string[]): Promise => {
- await AsyncStorage.setItem('customDisciplines', JSON.stringify(customDisciplines))
+const setCustomDisciplines = async (customDisciplines: string[]): Promise => {
+ await AsyncStorage.setItem(CUSTOM_DISCIPLINES_KEY, JSON.stringify(customDisciplines))
}
-export const removeCustomDiscipline = async (customDiscipline: string): Promise => {
+const removeCustomDiscipline = async (customDiscipline: string): Promise => {
const disciplines = await getCustomDisciplines()
const index = disciplines.indexOf(customDiscipline)
if (index === -1) {
@@ -56,7 +59,7 @@ export const removeCustomDiscipline = async (customDiscipline: string): Promise<
}
export const getExerciseProgress = async (): Promise